From 8ebc78751bf48457d7b2ba58bb1bda5fd36bbb58 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Thu, 2 Jul 2026 20:26:00 +0530 Subject: [PATCH 01/15] feat(logger): [SDK-4835] add KMP logger module as OpenTelemetry-free otel replacement Introduces a new Kotlin Multiplatform `logger` module that mirrors the decoupled architecture of the `otel` module but has zero OpenTelemetry dependency, so the same observability pipeline can be shared with iOS. - commonMain: LogRecord/LogSeverity model, platform/logger/telemetry/crash interfaces, hand-rolled OTLP/JSON encoder, coroutine batch processor, remote + crash-local telemetry, crash reporter/uploader, and LoggerFactory composition root. - HTTP, file IO, clock and uuid are injected (no Ktor/okio); iOS actuals stubbed. - commonTest: unit tests for encoder, fields, batching, telemetry, crash flows. - core: Android adapters (logger, platform provider, HTTP sender, file store, crash handler, ANR detector) + LoggerLifecycleManager, wired behind a single LoggerModuleSwitch.USE_LOGGER_MODULE toggle (default false keeps the otel path and all existing tests unchanged). Logging gains a parallel logger sink. otel module left intact; removal tracked as follow-up. Co-authored-by: Cursor --- OneSignalSDK/gradle.properties | 4 + OneSignalSDK/onesignal/core/build.gradle | 2 + .../core/src/main/AndroidManifest.xml | 6 +- .../crash/OneSignalCrashUploaderWrapper.kt | 21 +- .../debug/internal/logging/Logging.kt | 57 +++++ .../logging/logger/LoggerModuleSwitch.kt | 18 ++ .../logger/android/AndroidLogAnrDetector.kt | 122 +++++++++++ .../logger/android/AndroidLogCrashHandler.kt | 89 ++++++++ .../logging/logger/android/AndroidLogger.kt | 26 +++ .../logging/logger/android/FileLogStore.kt | 72 ++++++ .../logger/android/LoggerPlatformFactory.kt | 19 ++ .../android/LoggerPlatformProviderAdapter.kt | 48 ++++ .../logger/android/OneSignalLogHttpSender.kt | 55 +++++ .../IObservabilityLifecycleManager.kt | 19 ++ .../internal/LoggerLifecycleManager.kt | 206 ++++++++++++++++++ .../com/onesignal/internal/OneSignalImp.kt | 16 +- .../internal/OtelLifecycleManager.kt | 6 +- .../core/src/test/AndroidManifest.xml | 2 +- OneSignalSDK/onesignal/logger/build.gradle | 90 ++++++++ .../logger/internal/Platform.android.kt | 9 + .../kotlin/com/onesignal/logger/CrashData.kt | 17 ++ .../kotlin/com/onesignal/logger/ILogCrash.kt | 34 +++ .../com/onesignal/logger/ILogFileStore.kt | 46 ++++ .../com/onesignal/logger/ILogHttpSender.kt | 47 ++++ .../com/onesignal/logger/ILogTelemetry.kt | 36 +++ .../kotlin/com/onesignal/logger/ILogger.kt | 17 ++ .../logger/ILoggerPlatformProvider.kt | 84 +++++++ .../com/onesignal/logger/LogLoggingHelper.kt | 34 +++ .../kotlin/com/onesignal/logger/LogRecord.kt | 22 ++ .../com/onesignal/logger/LogSeverity.kt | 38 ++++ .../com/onesignal/logger/LoggerFactory.kt | 63 ++++++ .../onesignal/logger/attributes/LogFields.kt | 76 +++++++ .../logger/crash/LogCrashReporter.kt | 44 ++++ .../logger/crash/LogCrashUploader.kt | 67 ++++++ .../logger/internal/LogBatchProcessor.kt | 70 ++++++ .../onesignal/logger/internal/LogEndpoint.kt | 14 ++ .../logger/internal/LogTelemetryCrashImpl.kt | 43 ++++ .../logger/internal/LogTelemetryRemoteImpl.kt | 106 +++++++++ .../com/onesignal/logger/internal/Platform.kt | 12 + .../com/onesignal/logger/otlp/OtlpDtos.kt | 65 ++++++ .../onesignal/logger/otlp/OtlpLogEncoder.kt | 71 ++++++ .../onesignal/logger/LogBatchProcessorTest.kt | 41 ++++ .../com/onesignal/logger/LogCrashTest.kt | 94 ++++++++ .../com/onesignal/logger/LogFieldsTest.kt | 48 ++++ .../com/onesignal/logger/LogTelemetryTest.kt | 84 +++++++ .../onesignal/logger/OtlpLogEncoderTest.kt | 70 ++++++ .../kotlin/com/onesignal/logger/TestFakes.kt | 93 ++++++++ .../onesignal/logger/internal/Platform.ios.kt | 11 + OneSignalSDK/settings.gradle | 2 + 49 files changed, 2320 insertions(+), 16 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogAnrDetector.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogger.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/FileLogStore.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformFactory.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProviderAdapter.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/OneSignalLogHttpSender.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/IObservabilityLifecycleManager.kt create mode 100644 OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/LoggerLifecycleManager.kt create mode 100644 OneSignalSDK/onesignal/logger/build.gradle create mode 100644 OneSignalSDK/onesignal/logger/src/androidMain/kotlin/com/onesignal/logger/internal/Platform.android.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/CrashData.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogCrash.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogFileStore.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogHttpSender.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogTelemetry.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogger.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILoggerPlatformProvider.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogLoggingHelper.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogRecord.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogSeverity.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LoggerFactory.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/attributes/LogFields.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/crash/LogCrashReporter.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/crash/LogCrashUploader.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogBatchProcessor.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogEndpoint.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryCrashImpl.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryRemoteImpl.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/Platform.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpDtos.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpLogEncoder.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogBatchProcessorTest.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogCrashTest.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogFieldsTest.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogTelemetryTest.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/OtlpLogEncoderTest.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/TestFakes.kt create mode 100644 OneSignalSDK/onesignal/logger/src/iosMain/kotlin/com/onesignal/logger/internal/Platform.ios.kt diff --git a/OneSignalSDK/gradle.properties b/OneSignalSDK/gradle.properties index 6eddab7bd..3940db2e2 100644 --- a/OneSignalSDK/gradle.properties +++ b/OneSignalSDK/gradle.properties @@ -37,6 +37,10 @@ android.enableD8 = true # Android X settings android.useAndroidX = true +# The `logger` KMP module uses a newer AGP than the Kotlin MPP plugin has been +# tested against; the combination works for our targets, so silence the warning. +kotlin.mpp.androidGradlePluginCompatibility.nowarn = true + # This is the name of the SDK to use when building your project. # This will be fed from the GitHub Actions workflow. SDK_VERSION=5.9.5 diff --git a/OneSignalSDK/onesignal/core/build.gradle b/OneSignalSDK/onesignal/core/build.gradle index 6632ebc22..c9d526674 100644 --- a/OneSignalSDK/onesignal/core/build.gradle +++ b/OneSignalSDK/onesignal/core/build.gradle @@ -95,6 +95,8 @@ dependencies { // Otel module dependency implementation(project(':OneSignal:otel')) + // Logger module (KMP, OpenTelemetry-free) — the eventual replacement for :otel. + implementation(project(':OneSignal:logger')) testImplementation(project(':OneSignal:testhelpers')) testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") diff --git a/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml b/OneSignalSDK/onesignal/core/src/main/AndroidManifest.xml index 445d04058..9a891ea8e 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/debug/internal/crash/OneSignalCrashUploaderWrapper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt index aeac7c991..8569f6a7b 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 @@ -4,8 +4,14 @@ import com.onesignal.common.threading.OneSignalDispatchers 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.logger.LoggerModuleSwitch +import com.onesignal.debug.internal.logging.logger.android.AndroidLogger +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.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 @@ -34,7 +40,7 @@ internal class OneSignalCrashUploaderWrapper( private val applicationService: IApplicationService, private val featureManager: IFeatureManager, ) : IStartableService { - private val uploader: OtelCrashUploader by lazy { + 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( @@ -46,12 +52,23 @@ internal class OneSignalCrashUploaderWrapper( OtelFactory.createCrashUploader(platformProvider, logger) } + private val loggerUploader by lazy { + val platformProvider = createAndroidLoggerPlatformProvider(applicationService.appContext) { featureManager } + val remote = LoggerFactory.createRemoteTelemetry(platformProvider, OneSignalLogHttpSender()) + val fileStore = FileLogStore(platformProvider.crashStoragePath) + LoggerFactory.createCrashUploader(platformProvider, remote, fileStore, AndroidLogger()) + } + @Suppress("TooGenericExceptionCaught") override fun start() { if (!OtelSdkSupport.isSupported) return OneSignalDispatchers.launchOnIO { try { - uploader.start() + if (LoggerModuleSwitch.USE_LOGGER_MODULE) { + loggerUploader.start() + } else { + otelUploader.start() + } } catch (t: Throwable) { com.onesignal.debug.internal.logging.Logging.warn( "OneSignal: Crash uploader failed to start: ${t.message}", 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 673db1b8d..7c27cd4f9 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 @@ -6,6 +6,8 @@ import com.onesignal.core.internal.application.IApplicationService import com.onesignal.debug.ILogListener 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 @@ -52,6 +54,29 @@ object Logging { 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 }, + ) { + loggerRemoteTelemetry = telemetry + shouldSendLoggerLogLevel = shouldSend + } + // Coroutine scope for async Otel logging (non-blocking) private val otelLoggingScope = CoroutineScope(SupervisorJob() + Dispatchers.Default) @@ -132,6 +157,7 @@ object Logging { showVisualLogging(level, fullMessage, throwable) callLogListeners(level, fullMessage, throwable) logToOtel(level, fullMessage, throwable) + logToLogger(level, fullMessage, throwable) } private fun logToLogcat( @@ -235,6 +261,37 @@ object Logging { } } + /** + * 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 { + try { + LogLoggingHelper.log( + telemetry = telemetry, + level = level.name, + message = message, + exceptionType = throwable?.javaClass?.name, + exceptionMessage = throwable?.message, + exceptionStacktrace = throwable?.stackTraceToString(), + ) + } catch (t: Throwable) { + android.util.Log.e(TAG, "Failed to log to logger module: ${t.message}", t) + } + } + } + fun addListener(listener: ILogListener) { logListeners.add(listener) } 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 new file mode 100644 index 000000000..9cd971219 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt @@ -0,0 +1,18 @@ +package com.onesignal.debug.internal.logging.logger + +/** + * Single switch that routes the SDK's observability (remote logging, crash capture, + * crash upload, ANR detection) through either: + * - the legacy OpenTelemetry-based `otel` module (default), or + * - the new, multiplatform, OpenTelemetry-free `logger` module. + * + * Flip [USE_LOGGER_MODULE] to `true` to exercise the `logger` module end-to-end on + * Android. Keeping it `false` by default leaves the existing otel path — and all of + * its tests — completely unchanged, so the swap is a one-line, low-risk toggle. + * + * Once the `logger` module has been validated in production, the otel path (and this + * switch) can be removed along with the `:otel` module. + */ +internal object LoggerModuleSwitch { + const val USE_LOGGER_MODULE = false +} 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 new file mode 100644 index 000000000..4a376372a --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogAnrDetector.kt @@ -0,0 +1,122 @@ +package com.onesignal.debug.internal.logging.logger.android + +import android.os.Handler +import android.os.Looper +import com.onesignal.debug.internal.crash.AnrConstants +import com.onesignal.logger.CrashData +import com.onesignal.logger.ILogAnrDetector +import com.onesignal.logger.ILogCrashReporter +import com.onesignal.logger.ILogger +import kotlinx.coroutines.runBlocking +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicLong + +/** + * Android [ILogAnrDetector] — watchdog that detects when the main thread is blocked + * and, if OneSignal is at fault, persists an ANR report via the `logger` module's + * [ILogCrashReporter]. Direct analogue of `OtelAnrDetector`, but transport-agnostic. + */ +internal class AndroidLogAnrDetector( + private val crashReporter: ILogCrashReporter, + private val logger: ILogger, + private val anrThresholdMs: Long = AnrConstants.DEFAULT_ANR_THRESHOLD_MS, + private val checkIntervalMs: Long = AnrConstants.DEFAULT_CHECK_INTERVAL_MS, +) : ILogAnrDetector { + private val mainHandler = Handler(Looper.getMainLooper()) + private val isMonitoring = AtomicBoolean(false) + private val lastResponseTime = AtomicLong(System.currentTimeMillis()) + private val lastAnrReportTime = AtomicLong(0L) + private var watchdogThread: Thread? = null + private var watchdogRunnable: Runnable? = null + private var mainThreadRunnable: Runnable? = null + + private companion object { + const val TAG = "AndroidLogAnrDetector" + const val MIN_TIME_BETWEEN_ANR_REPORTS_MS = 30_000L + } + + override fun start() { + if (isMonitoring.getAndSet(true)) { + logger.warn("$TAG: already monitoring, skipping start") + return + } + setupRunnables() + watchdogThread = Thread(watchdogRunnable, "OneSignal-Log-ANR-Watchdog").apply { + isDaemon = true + start() + } + logger.info("$TAG: ANR detection started") + } + + @Suppress("TooGenericExceptionCaught") + private fun setupRunnables() { + mainThreadRunnable = Runnable { lastResponseTime.set(System.currentTimeMillis()) } + watchdogRunnable = Runnable { + while (isMonitoring.get()) { + try { + checkForAnr() + } catch (e: InterruptedException) { + break + } catch (t: Throwable) { + logger.error("$TAG: error in watchdog: ${t.message}") + } + } + } + } + + private fun checkForAnr() { + val runnable = mainThreadRunnable ?: return + mainHandler.post(runnable) + Thread.sleep(checkIntervalMs) + val timeSinceLastResponse = System.currentTimeMillis() - lastResponseTime.get() + if (timeSinceLastResponse > anrThresholdMs) { + handleAnrDetected(timeSinceLastResponse) + } else if (lastAnrReportTime.get() > 0) { + lastAnrReportTime.set(0L) + } + } + + private fun handleAnrDetected(timeSinceLastResponse: Long) { + val now = System.currentTimeMillis() + if (now - lastAnrReportTime.get() > MIN_TIME_BETWEEN_ANR_REPORTS_MS) { + lastAnrReportTime.set(now) + reportAnr(timeSinceLastResponse) + } + } + + override fun stop() { + if (!isMonitoring.getAndSet(false)) { + logger.warn("$TAG: not monitoring, skipping stop") + return + } + watchdogThread?.interrupt() + watchdogThread = null + watchdogRunnable = null + mainThreadRunnable?.let { mainHandler.removeCallbacks(it) } + mainThreadRunnable = null + logger.info("$TAG: ANR detection stopped") + } + + @Suppress("TooGenericExceptionCaught") + private fun reportAnr(unresponsiveDurationMs: Long) { + try { + val mainThread = Looper.getMainLooper().thread + val stackTrace = mainThread.stackTrace + if (!isOneSignalAtFault(stackTrace)) { + 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() }, + ) + runBlocking { crashReporter.saveCrash(crash) } + logger.info("$TAG: ANR report saved") + } catch (t: Throwable) { + logger.error("$TAG: failed to report ANR: ${t.message}") + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt new file mode 100644 index 000000000..a57415f59 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt @@ -0,0 +1,89 @@ +package com.onesignal.debug.internal.logging.logger.android + +import com.onesignal.logger.CrashData +import com.onesignal.logger.ILogCrashHandler +import com.onesignal.logger.ILogCrashReporter +import com.onesignal.logger.ILogger +import kotlinx.coroutines.runBlocking + +/** + * Android [ILogCrashHandler] — installs a [Thread.UncaughtExceptionHandler] that + * captures OneSignal-related crashes and hands a platform-neutral [CrashData] to the + * `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`. + */ +internal class AndroidLogCrashHandler( + private val crashReporter: ILogCrashReporter, + private val logger: ILogger, +) : Thread.UncaughtExceptionHandler, ILogCrashHandler { + private var existingHandler: Thread.UncaughtExceptionHandler? = null + private val seenThrowables: MutableList = mutableListOf() + + @Volatile + private var initialized = false + + override fun initialize() { + if (initialized) { + logger.warn("AndroidLogCrashHandler already initialized, skipping") + return + } + existingHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler(this) + initialized = true + logger.info("AndroidLogCrashHandler: registered as default uncaught exception handler") + } + + override fun unregister() { + if (!initialized) { + logger.debug("AndroidLogCrashHandler: not initialized, nothing to unregister") + return + } + Thread.setDefaultUncaughtExceptionHandler(existingHandler) + existingHandler = null + initialized = false + } + + @Suppress("TooGenericExceptionCaught") + override fun uncaughtException(thread: Thread, throwable: Throwable) { + synchronized(seenThrowables) { + if (seenThrowables.contains(throwable)) { + logger.warn("AndroidLogCrashHandler: ignoring duplicate throwable instance") + return + } + seenThrowables.add(throwable) + } + + val isAnr = + throwable.javaClass.simpleName.contains("ApplicationNotResponding", ignoreCase = true) || + throwable.message?.contains("Application Not Responding", ignoreCase = true) == true + + if (!isAnr && !isOneSignalAtFault(throwable.stackTrace)) { + logger.debug("AndroidLogCrashHandler: crash not OneSignal-related, delegating") + existingHandler?.uncaughtException(thread, throwable) + return + } + + logger.info("AndroidLogCrashHandler: OneSignal-related crash detected, saving report") + try { + runBlocking { crashReporter.saveCrash(throwable.toCrashData(thread)) } + } catch (t: Throwable) { + logger.error("AndroidLogCrashHandler: failed to save crash report: ${t.message}") + } + existingHandler?.uncaughtException(thread, throwable) + } +} + +internal fun Throwable.toCrashData(thread: Thread): CrashData = + CrashData( + threadName = thread.name, + exceptionType = this::class.java.name, + exceptionMessage = message ?: "", + stacktrace = stackTraceToString(), + ) + +/** True when any frame originates from OneSignal SDK code. */ +internal fun isOneSignalAtFault(stackTrace: Array): Boolean = + stackTrace.any { it.className.startsWith("com.onesignal") } 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 new file mode 100644 index 000000000..0838ed5e5 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogger.kt @@ -0,0 +1,26 @@ +package com.onesignal.debug.internal.logging.logger.android + +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.logger.ILogger + +/** + * Android implementation of [ILogger] for the `logger` module. Delegates to the + * existing [Logging] object. Direct analogue of `AndroidOtelLogger`. + */ +internal class AndroidLogger : ILogger { + 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/debug/internal/logging/logger/android/FileLogStore.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/FileLogStore.kt new file mode 100644 index 000000000..b5dcf9c6d --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/FileLogStore.kt @@ -0,0 +1,72 @@ +package com.onesignal.debug.internal.logging.logger.android + +import com.onesignal.logger.ILogFileStore +import com.onesignal.logger.StoredLogFile +import java.io.File +import java.util.UUID + +/** + * Android [ILogFileStore] backed by the local filesystem. Replaces OpenTelemetry's + * `disk-buffering` contrib library with a trivial one-file-per-record format we own. + * + * 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). + */ +internal class FileLogStore( + private val rootPath: String, +) : ILogFileStore { + private val rootDir: File get() = File(rootPath) + + private companion object { + const val FILE_SUFFIX = ".otlp" + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + override fun save(bytes: ByteArray) { + try { + 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()}$FILE_SUFFIX") + val temp = File(dir, target.name + ".tmp") + temp.writeBytes(bytes) + if (!temp.renameTo(target)) { + // Fallback: write directly if rename is unsupported on this fs. + target.writeBytes(bytes) + temp.delete() + } + } catch (t: Throwable) { + // Crash-path safety: never throw from persistence. + } + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + override fun listReadable(minAgeMillis: Long): List { + return try { + val now = System.currentTimeMillis() + rootDir.listFiles { file -> file.isFile && file.name.endsWith(FILE_SUFFIX) } + ?.filter { now - it.lastModified() >= minAgeMillis } + ?.mapNotNull { file -> + try { + StoredLogFile(id = file.name, bytes = file.readBytes()) + } catch (t: Throwable) { + null + } + } + ?: emptyList() + } catch (t: Throwable) { + emptyList() + } + } + + @Suppress("TooGenericExceptionCaught", "SwallowedException") + override fun delete(id: String) { + try { + File(rootDir, id).delete() + } catch (t: Throwable) { + // best-effort + } + } +} 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 new file mode 100644 index 000000000..34bfff504 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformFactory.kt @@ -0,0 +1,19 @@ +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/logger/android/LoggerPlatformProviderAdapter.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProviderAdapter.kt new file mode 100644 index 000000000..f96a9e344 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/LoggerPlatformProviderAdapter.kt @@ -0,0 +1,48 @@ +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 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 new file mode 100644 index 000000000..25946cd3f --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/OneSignalLogHttpSender.kt @@ -0,0 +1,55 @@ +package com.onesignal.debug.internal.logging.logger.android + +import com.onesignal.logger.ILogHttpSender +import com.onesignal.logger.LogHttpRequest +import com.onesignal.logger.LogHttpResponse +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.net.HttpURLConnection +import java.net.URL + +/** + * Android [ILogHttpSender] backed by [HttpURLConnection]. + * + * The `logger` module deliberately has no networking dependency, so the platform + * supplies the transport. This keeps the module pure-Kotlin/multiplatform while + * routing all SDK log traffic through a standard, well-understood HTTP client. + */ +internal class OneSignalLogHttpSender : ILogHttpSender { + companion object { + private const val CONNECT_TIMEOUT_MS = 10_000 + private const val READ_TIMEOUT_MS = 10_000 + private const val HTTP_OK_MIN = 200 + private const val HTTP_OK_MAX = 299 + } + + @Suppress("TooGenericExceptionCaught") + override suspend fun send(request: LogHttpRequest): LogHttpResponse = + withContext(Dispatchers.IO) { + var connection: HttpURLConnection? = null + try { + connection = (URL(request.url).openConnection() as HttpURLConnection).apply { + requestMethod = "POST" + connectTimeout = CONNECT_TIMEOUT_MS + readTimeout = READ_TIMEOUT_MS + doOutput = true + setRequestProperty("Content-Type", request.contentType) + request.headers.forEach { (key, value) -> setRequestProperty(key, value) } + } + + connection.outputStream.use { it.write(request.body) } + + val code = connection.responseCode + val success = code in HTTP_OK_MIN..HTTP_OK_MAX + if (!success) { + // Drain the error stream so the connection can be reused/closed cleanly. + connection.errorStream?.use { it.readBytes() } + } + LogHttpResponse(success = success, statusCode = code) + } catch (t: Throwable) { + LogHttpResponse(success = false, statusCode = -1, message = t.message) + } finally { + connection?.disconnect() + } + } +} 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 new file mode 100644 index 000000000..1704e41ef --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/IObservabilityLifecycleManager.kt @@ -0,0 +1,19 @@ +package com.onesignal.internal + +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. + */ +internal interface IObservabilityLifecycleManager { + /** Boots whichever features are already enabled from cached config at cold start. */ + fun initializeFromCachedConfig() + + /** Subscribes to config store change events so features react to fresh remote config. */ + fun subscribeToConfigStore(configModelStore: ConfigModelStore) +} 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 new file mode 100644 index 000000000..26b53411b --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/LoggerLifecycleManager.kt @@ -0,0 +1,206 @@ +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.OtelSdkSupport +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 +import com.onesignal.debug.internal.logging.logger.android.AndroidLogger +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.logger.ILogAnrDetector +import com.onesignal.logger.ILogCrashHandler +import com.onesignal.logger.ILogTelemetryRemote +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.USE_LOGGER_MODULE] + * is true; otherwise [OtelLifecycleManager] is used instead. + */ +@Suppress("TooManyFunctions") +internal class LoggerLifecycleManager( + private val context: Context, + private val featureManagerProvider: () -> IFeatureManager, +) : ISingletonModelStoreChangeHandler, IObservabilityLifecycleManager { + private val lock = Any() + + private val platformProvider: ILoggerPlatformProvider by lazy { + createAndroidLoggerPlatformProvider(context, featureManagerProvider) + } + + private val logger = AndroidLogger() + private val httpSender = OneSignalLogHttpSender() + + private val fileStore: FileLogStore by lazy { FileLogStore(platformProvider.crashStoragePath) } + + private var crashHandler: ILogCrashHandler? = null + private var anrDetector: ILogAnrDetector? = null + private var remoteTelemetry: ILogTelemetryRemote? = null + private var currentConfig: OtelConfig? = null + + @Suppress("TooGenericExceptionCaught") + override fun initializeFromCachedConfig() { + if (!OtelSdkSupport.isSupported) { + Logging.info("OneSignal: Device SDK < ${OtelSdkSupport.MIN_SDK_VERSION}, logger module not supported — skipping") + 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 logger module from cached config: ${t.message}", t) + } + } + + override fun subscribeToConfigStore(configModelStore: ConfigModelStore) { + configModelStore.subscribe(this) + } + + @Suppress("TooGenericExceptionCaught") + override fun onModelReplaced(model: ConfigModel, tag: String) { + if (tag != ModelChangeTags.HYDRATE) return + if (!OtelSdkSupport.isSupported) return + try { + val newConfig = + OtelConfig( + isEnabled = model.remoteLoggingParams.isEnabled, + logLevel = model.remoteLoggingParams.logLevel, + ) + synchronized(lock) { + val action = OtelConfigEvaluator.evaluate(old = currentConfig, new = newConfig) + applyAction(action, newConfig) + } + } catch (t: Throwable) { + Logging.warn("OneSignal: Failed to refresh logger module from remote config: ${t.message}", t) + } + } + + override fun onModelUpdated(args: ModelChangedArgs, tag: String) { + // Only full model replacements (HYDRATE) matter here. + } + + 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]. */ + private fun applyAction(action: OtelConfigAction, newConfig: OtelConfig) { + when (action) { + is OtelConfigAction.Enable -> enableFeatures(newConfig.logLevel ?: LogLevel.ERROR) + is OtelConfigAction.Disable -> disableFeatures() + is OtelConfigAction.UpdateLogLevel -> updateLogLevel(action.newLevel) + is OtelConfigAction.NoChange -> Logging.debug("OneSignal: logger config unchanged") + } + currentConfig = newConfig + } + + @Suppress("TooGenericExceptionCaught") + private fun enableFeatures(logLevel: LogLevel) { + Logging.info("OneSignal: Enabling logger module features at level $logLevel") + try { + startCrashHandler() + } catch (t: Throwable) { + Logging.warn("OneSignal: Failed to start logger crash handler: ${t.message}", t) + } + try { + startAnrDetector() + } catch (t: Throwable) { + Logging.warn("OneSignal: Failed to start logger ANR detector: ${t.message}", t) + } + try { + startLogging(logLevel) + } catch (t: Throwable) { + Logging.warn("OneSignal: Failed to start logger logging: ${t.message}", t) + } + } + + @Suppress("TooGenericExceptionCaught") + private fun disableFeatures() { + Logging.info("OneSignal: Disabling logger module features") + try { + anrDetector?.stop() + anrDetector = null + } catch (t: Throwable) { + Logging.warn("OneSignal: Error stopping logger ANR detector: ${t.message}", t) + } + try { + crashHandler?.unregister() + crashHandler = null + } catch (t: Throwable) { + Logging.warn("OneSignal: Error unregistering logger crash handler: ${t.message}", t) + } + try { + Logging.setLoggerTelemetry(null) { false } + remoteTelemetry?.shutdown() + remoteTelemetry = null + } catch (t: Throwable) { + Logging.warn("OneSignal: Error disabling logger logging: ${t.message}", t) + } + } + + @Suppress("TooGenericExceptionCaught") + private fun updateLogLevel(newLevel: LogLevel) { + Logging.info("OneSignal: Updating logger module log level to $newLevel") + try { + startLogging(newLevel) + } catch (t: Throwable) { + Logging.warn("OneSignal: Failed to update logger log level: ${t.message}", t) + } + } + + private fun startCrashHandler() { + if (crashHandler != null) return + val crashTelemetry = LoggerFactory.createCrashLocalTelemetry(platformProvider, fileStore) + val reporter = LoggerFactory.createCrashReporter(crashTelemetry, logger) + val handler = AndroidLogCrashHandler(reporter, logger) + handler.initialize() + crashHandler = handler + Logging.info("OneSignal: logger crash handler initialized — logs at: ${platformProvider.crashStoragePath}") + } + + 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, + ) + detector.start() + anrDetector = detector + Logging.info("OneSignal: logger ANR detector started") + } + + private fun startLogging(logLevel: LogLevel) { + remoteTelemetry?.shutdown() + val telemetry = LoggerFactory.createRemoteTelemetry(platformProvider, httpSender) + remoteTelemetry = telemetry + val shouldSend: (LogLevel) -> Boolean = { level -> + logLevel != LogLevel.NONE && level <= logLevel + } + Logging.setLoggerTelemetry(telemetry, shouldSend) + Logging.info("OneSignal: logger module logging active at level $logLevel") + } +} 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 a12f4a438..e89307a28 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 @@ -61,7 +61,7 @@ internal class OneSignalImp : IOneSignal, // Save the exception pointing to the caller that triggered init, not the async worker thread. private var initFailureException: Exception? = null - private var otelManager: OtelLifecycleManager? = null + private var observabilityManager: IObservabilityLifecycleManager? = null override val sdkVersion: String = OneSignalUtils.sdkVersion @@ -239,11 +239,13 @@ internal class OneSignalImp : IOneSignal, // 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. - otelManager = - OtelLifecycleManager( - context = context, - featureManagerProvider = { services.getService() }, - ).also { it.initializeFromCachedConfig() } + val featureManagerProvider = { services.getService() } + observabilityManager = + if (com.onesignal.debug.internal.logging.logger.LoggerModuleSwitch.USE_LOGGER_MODULE) { + LoggerLifecycleManager(context = context, featureManagerProvider = featureManagerProvider) + } else { + OtelLifecycleManager(context = context, featureManagerProvider = featureManagerProvider) + }.also { it.initializeFromCachedConfig() } PreferenceStoreFix.ensureNoObfuscatedPrefStore(context) @@ -387,7 +389,7 @@ internal class OneSignalImp : IOneSignal, // Now that the IoC container is ready, subscribe the Otel lifecycle // manager to config store events so it reacts to fresh remote config. - otelManager?.subscribeToConfigStore(services.getService()) + observabilityManager?.subscribeToConfigStore(services.getService()) val result = resolveAppId(appId, configModel, preferencesService) if (result.failed) { 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 index 62263470a..a9de086d0 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OtelLifecycleManager.kt @@ -54,7 +54,7 @@ internal class OtelLifecycleManager( private val platformProviderFactory: (Context, () -> IFeatureManager) -> OtelPlatformProvider = { ctx, fm -> createAndroidOtelPlatformProvider(ctx, fm) }, private val loggerFactory: () -> IOtelLogger = { AndroidOtelLogger() }, -) : ISingletonModelStoreChangeHandler { +) : ISingletonModelStoreChangeHandler, IObservabilityLifecycleManager { private val lock = Any() private val platformProvider: OtelPlatformProvider by lazy { @@ -74,7 +74,7 @@ internal class OtelLifecycleManager( * whichever features are already enabled. */ @Suppress("TooGenericExceptionCaught") - fun initializeFromCachedConfig() { + override fun initializeFromCachedConfig() { if (!OtelSdkSupport.isSupported) { Logging.info("OneSignal: Device SDK < ${OtelSdkSupport.MIN_SDK_VERSION}, Otel not supported — skipping all Otel features") return @@ -95,7 +95,7 @@ internal class OtelLifecycleManager( * Subscribes this manager to config store change events. * Call after the IoC container is bootstrapped (i.e. after [bootstrapServices]). */ - fun subscribeToConfigStore(configModelStore: ConfigModelStore) { + override fun subscribeToConfigStore(configModelStore: ConfigModelStore) { configModelStore.subscribe(this) } diff --git a/OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml b/OneSignalSDK/onesignal/core/src/test/AndroidManifest.xml index 8768713b9..04ba2503d 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.otel, com.onesignal.logger" /> diff --git a/OneSignalSDK/onesignal/logger/build.gradle b/OneSignalSDK/onesignal/logger/build.gradle new file mode 100644 index 000000000..60deed535 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/build.gradle @@ -0,0 +1,90 @@ +plugins { + id 'org.jetbrains.kotlin.multiplatform' + id 'com.android.library' + id 'org.jetbrains.kotlin.plugin.serialization' + id 'com.diffplug.spotless' +} + +// Kotlin Multiplatform "logger" module. +// +// This is the platform-agnostic, OpenTelemetry-free replacement for the `otel` +// module. ALL logic lives in `commonMain` (pure Kotlin, no Android or JVM-only +// OpenTelemetry types) so the exact same pipeline can be shared with the iOS SDK. +// +// The module deliberately follows the same decoupled shape as `otel`: +// - platform values are injected via ILoggerPlatformProvider +// - logging is injected via ILogger +// - HTTP and file IO are injected via ILogHttpSender / ILogFileStore +// - everything is composed through LoggerFactory +// This keeps the module honest about its dependencies (i.e. "not so easy to use") +// while making the eventual core swap from `otel` -> `logger` near-mechanical. + +kotlin { + androidTarget { + compilations.all { + kotlinOptions { + jvmTarget = '1.8' + } + } + } + + // iOS targets — these make the module genuinely multiplatform. They are not + // compiled by the Android build (Android tasks only touch the android target), + // so they don't block the Android swap-and-test goal. + iosX64() + iosArm64() + iosSimulatorArm64() + + sourceSets { + commonMain { + dependencies { + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationJsonVersion" + } + } + commonTest { + dependencies { + implementation kotlin('test') + implementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" + implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationJsonVersion" + } + } + } +} + +android { + namespace 'com.onesignal.logger' + compileSdkVersion rootProject.buildVersions.compileSdkVersion + + defaultConfig { + // Matches the `otel` module. Runtime callers gate usage on SDK level the + // same way `otel` does, so consuming modules (min 21) override the merge. + minSdkVersion 26 + } + + // Must mirror the build types declared by consuming modules (e.g. :core) so + // Gradle variant matching can resolve this module for every variant. + buildTypes { + original { + minifyEnabled false + } + release { + minifyEnabled false + } + unity { + minifyEnabled false + } + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } +} + +ext { + projectName = "OneSignal SDK Logger" + projectDescription = "OneSignal SDK - Multiplatform logging/telemetry module" +} + +apply from: '../spotless.gradle' diff --git a/OneSignalSDK/onesignal/logger/src/androidMain/kotlin/com/onesignal/logger/internal/Platform.android.kt b/OneSignalSDK/onesignal/logger/src/androidMain/kotlin/com/onesignal/logger/internal/Platform.android.kt new file mode 100644 index 000000000..a66fcc98a --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/androidMain/kotlin/com/onesignal/logger/internal/Platform.android.kt @@ -0,0 +1,9 @@ +package com.onesignal.logger.internal + +import java.util.UUID + +internal actual fun epochNanosNow(): Long = System.currentTimeMillis() * NANOS_PER_MILLI + +internal actual fun randomUuidString(): String = UUID.randomUUID().toString() + +private const val NANOS_PER_MILLI = 1_000_000L diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/CrashData.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/CrashData.kt new file mode 100644 index 000000000..7f089b451 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/CrashData.kt @@ -0,0 +1,17 @@ +package com.onesignal.logger + +/** + * Platform-neutral description of a crash. + * + * The old `otel` module took a JVM `Thread` + `Throwable` directly, which is not + * expressible in `commonMain`. Crash *capture* is inherently platform-specific + * (Android: `Thread.UncaughtExceptionHandler`; iOS: signal/NSException handlers), + * so the platform is responsible for translating its native crash representation + * into this neutral shape before handing it to [ILogCrashReporter]. + */ +data class CrashData( + val threadName: String, + val exceptionType: String, + val exceptionMessage: String, + val stacktrace: String, +) diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogCrash.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogCrash.kt new file mode 100644 index 000000000..0fdf750a0 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogCrash.kt @@ -0,0 +1,34 @@ +package com.onesignal.logger + +/** + * Platform-agnostic crash reporter. Persists a captured crash so it can be shipped + * on the next launch. + */ +interface ILogCrashReporter { + suspend fun saveCrash(crash: CrashData) +} + +/** + * Platform-agnostic crash handler. Registration of the native handler is + * platform-specific (Android: `Thread.UncaughtExceptionHandler`), so the + * implementation lives in the platform layer; this interface is the contract the + * lifecycle owner uses. + */ +interface ILogCrashHandler { + /** Installs the crash handler. Call as early as possible. */ + fun initialize() + + /** Restores the previous handler. Safe to call if never initialized. */ + fun unregister() +} + +/** + * Platform-agnostic ANR (Application Not Responding) detector. ANRs are detected + * by monitoring main-thread responsiveness, which is platform-specific, so the + * implementation lives in the platform layer. + */ +interface ILogAnrDetector { + fun start() + + fun stop() +} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogFileStore.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogFileStore.kt new file mode 100644 index 000000000..39bd4f3a6 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogFileStore.kt @@ -0,0 +1,46 @@ +package com.onesignal.logger + +/** + * A stored crash record on disk. + * + * @property id opaque identifier (the platform decides what this is — e.g. a file + * name). Used to [ILogFileStore.read] and [ILogFileStore.delete] the entry. + * @property bytes the encoded payload that was written. + */ +data class StoredLogFile( + val id: String, + val bytes: ByteArray, +) { + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is StoredLogFile) return false + return id == other.id && bytes.contentEquals(other.bytes) + } + + override fun hashCode(): Int = 31 * id.hashCode() + bytes.contentHashCode() +} + +/** + * Platform-agnostic durable store for crash records. + * + * Replaces OpenTelemetry's `disk-buffering` contrib library. The on-disk format is + * entirely owned by this module (each record is one encoded OTLP/JSON payload), so + * the same logic works on any platform that can provide simple file primitives. + * + * Implementations must be safe to call from a crash path (i.e. cheap, no heavy + * initialization). + */ +interface ILogFileStore { + /** Persists [bytes] under a newly generated entry. */ + fun save(bytes: ByteArray) + + /** + * Returns all readable entries whose age is at least [minAgeMillis]. The age + * gate mirrors `minFileAgeForReadMillis` from the old pipeline: it guarantees + * we never read a file that may still be mid-write from the crashing process. + */ + fun listReadable(minAgeMillis: Long): List + + /** Deletes the entry with the given [id]. Safe to call if already gone. */ + fun delete(id: String) +} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogHttpSender.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogHttpSender.kt new file mode 100644 index 000000000..2d0a4b584 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogHttpSender.kt @@ -0,0 +1,47 @@ +package com.onesignal.logger + +/** + * Outbound HTTP request for log export. Body is already-encoded bytes (e.g. OTLP + * JSON) with the matching [contentType]. + */ +data class LogHttpRequest( + val url: String, + val headers: Map, + val contentType: String, + val body: ByteArray, +) { + // Generated equals/hashCode for data classes with ByteArray are reference-based, + // which is fine here (requests are not used as map keys), but override for sanity. + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other !is LogHttpRequest) return false + return url == other.url && + headers == other.headers && + contentType == other.contentType && + body.contentEquals(other.body) + } + + override fun hashCode(): Int { + var result = url.hashCode() + result = 31 * result + headers.hashCode() + result = 31 * result + contentType.hashCode() + result = 31 * result + body.contentHashCode() + return result + } +} + +/** Result of a [ILogHttpSender] call. */ +data class LogHttpResponse( + val success: Boolean, + val statusCode: Int, + val message: String? = null, +) + +/** + * Platform-agnostic HTTP transport for shipping logs. Implemented by the platform + * (Android reuses the SDK's existing HTTP stack), keeping this module free of any + * networking dependency. + */ +interface ILogHttpSender { + suspend fun send(request: LogHttpRequest): LogHttpResponse +} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogTelemetry.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogTelemetry.kt new file mode 100644 index 000000000..933ce8fe8 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogTelemetry.kt @@ -0,0 +1,36 @@ +package com.onesignal.logger + +/** + * Platform-agnostic telemetry sink. Analogue of `IOtelOpenTelemetry`, but the + * public surface only uses plain Kotlin types (no OpenTelemetry types leak out). + */ +interface ILogTelemetry { + /** + * Records a single log record. For the remote sink this enqueues the record + * into the batch processor; for the crash sink this writes it to disk. + */ + suspend fun emit(record: LogRecord) + + /** Forces all pending records to be exported/persisted immediately. */ + suspend fun forceFlush() + + /** + * Shuts down the sink, flushing pending data and releasing resources. After + * this call the instance must not be reused. + */ + fun shutdown() +} + +/** Telemetry sink that ships records over the network (batched). */ +interface ILogTelemetryRemote : ILogTelemetry { + /** + * POSTs a pre-encoded payload immediately, bypassing the batch queue, and + * returns whether the export succeeded. Used by the crash uploader to ship + * disk-buffered crash records (which are stored already-encoded) on the next + * launch, preserving their original capture-time resource attributes. + */ + suspend fun exportEncoded(payload: ByteArray): Boolean +} + +/** Telemetry sink that persists records to local storage (crash buffering). */ +interface ILogTelemetryCrash : ILogTelemetry diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogger.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogger.kt new file mode 100644 index 000000000..312f7bb66 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogger.kt @@ -0,0 +1,17 @@ +package com.onesignal.logger + +/** + * Platform-agnostic logger interface for the logger module. + * + * Mirrors `IOtelLogger`. Implementations are provided by the platform (on Android + * this delegates to the core `Logging` object). + */ +interface ILogger { + fun error(message: String) + + fun warn(message: String) + + fun info(message: String) + + fun debug(message: String) +} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILoggerPlatformProvider.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILoggerPlatformProvider.kt new file mode 100644 index 000000000..ef4ca14ed --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILoggerPlatformProvider.kt @@ -0,0 +1,84 @@ +package com.onesignal.logger + +/** + * Platform-agnostic provider for all platform-specific values the logger pipeline + * needs. This is the direct analogue of `IOtelPlatformProvider`. + * + * Everything the module cannot compute in pure common code — device/app metadata, + * IDs, config, storage location — is injected here. Implementations live in the + * platform layer (Android: core module; iOS: Swift-backed actual later). + */ +interface ILoggerPlatformProvider { + // ---- Top-level / resource attributes (static, calculated once) ---- + + /** + * Installation ID for this device. Suspending because it may need to generate + * and persist a new ID on first access. + */ + 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? + + /** + * Canonical keys of feature flags currently enabled for this device. Read + * fresh on every access so per-event attributes reflect the current state. + * Defaults to empty for source/binary compatibility. + */ + val enabledFeatureFlags: List + get() = emptyList() + + // ---- Per-event attributes (dynamic, calculated per event) ---- + + val appId: String? + val onesignalId: String? + val pushSubscriptionId: String? + + /** "foreground", "background", or "unknown". */ + val appState: String + + /** Process uptime in milliseconds. */ + val processUptime: Long + + 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. Defaults to false on first launch (before remote config cached). + */ + val isRemoteLoggingEnabled: Boolean + + /** + * Minimum log level to send remotely (e.g. "ERROR", "WARN"). Null when remote + * logging config is empty / not yet cached. + */ + val remoteLogLevel: String? + + /** Debug-only toggle for local exporter diagnostics. */ + val isExporterLoggingEnabled: Boolean + + /** App id used for the `app_id` query param / headers. */ + val appIdForHeaders: String + + /** + * Base URL for the OneSignal API (e.g. "https://api.onesignal.com"). + * The log endpoint path is appended to this. + */ + val apiBaseUrl: String +} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogLoggingHelper.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogLoggingHelper.kt new file mode 100644 index 000000000..1eaee83ee --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogLoggingHelper.kt @@ -0,0 +1,34 @@ +package com.onesignal.logger + +/** + * Helper for turning a log call into a [LogRecord] and emitting it. Mirrors + * `OtelLoggingHelper`, giving the platform `Logging` integration a single, stable + * entry point that hides the record-construction details. + */ +object LogLoggingHelper { + suspend fun log( + telemetry: ILogTelemetry, + level: String, + message: String, + exceptionType: String? = null, + exceptionMessage: String? = null, + exceptionStacktrace: String? = null, + ) { + val attributes = + buildMap { + put("log.message", message) + put("log.level", level) + if (exceptionType != null) put("exception.type", exceptionType) + if (exceptionMessage != null) put("exception.message", exceptionMessage) + if (exceptionStacktrace != null) put("exception.stacktrace", exceptionStacktrace) + } + + telemetry.emit( + LogRecord( + severity = LogSeverity.fromLevelName(level), + body = message, + attributes = attributes, + ), + ) + } +} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogRecord.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogRecord.kt new file mode 100644 index 000000000..41bc0455a --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogRecord.kt @@ -0,0 +1,22 @@ +package com.onesignal.logger + +/** + * A single, fully platform-agnostic log record. + * + * This replaces the OpenTelemetry `LogRecordBuilder` / `LogRecordData` types that + * the old `otel` module leaked across its public surface. A record carries only + * its own (record-specific) attributes; the telemetry implementation merges in + * the per-event and top-level/resource attributes at emit/encode time. + * + * @property severity log severity + * @property body human-readable message (becomes the OTLP log body) + * @property attributes record-specific attributes (e.g. exception.* fields) + * @property timestampNanos event time in nanoseconds since the Unix epoch; when + * `null` the telemetry stamps it at emit time. + */ +data class LogRecord( + val severity: LogSeverity, + val body: String, + val attributes: Map = emptyMap(), + val timestampNanos: Long? = null, +) diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogSeverity.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogSeverity.kt new file mode 100644 index 000000000..61361d2cc --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogSeverity.kt @@ -0,0 +1,38 @@ +package com.onesignal.logger + +/** + * Platform-agnostic severity model. + * + * Numeric values follow the OpenTelemetry log severity number scheme so that + * payloads remain wire-compatible with the existing OTLP ingestion endpoint, + * even though this module no longer depends on the OpenTelemetry SDK. + * + * See: https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber + */ +enum class LogSeverity(val severityNumber: Int, val severityText: String) { + TRACE(1, "TRACE"), + DEBUG(5, "DEBUG"), + INFO(9, "INFO"), + WARN(13, "WARN"), + ERROR(17, "ERROR"), + FATAL(21, "FATAL"), + ; + + companion object { + /** + * Maps a OneSignal [com.onesignal.debug.LogLevel] name (or any case variant) + * to a severity. Unknown values fall back to [INFO] to match the previous + * OtelLoggingHelper behavior. + */ + fun fromLevelName(level: String): LogSeverity = + when (level.uppercase()) { + "VERBOSE" -> TRACE + "DEBUG" -> DEBUG + "INFO" -> INFO + "WARN" -> WARN + "ERROR" -> ERROR + "FATAL" -> FATAL + else -> INFO + } + } +} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LoggerFactory.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LoggerFactory.kt new file mode 100644 index 000000000..1568dfe3a --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LoggerFactory.kt @@ -0,0 +1,63 @@ +package com.onesignal.logger + +import com.onesignal.logger.attributes.LogFieldsPerEvent +import com.onesignal.logger.attributes.LogFieldsTopLevel +import com.onesignal.logger.crash.LogCrashReporter +import com.onesignal.logger.crash.LogCrashUploader +import com.onesignal.logger.internal.LogTelemetryCrashImpl +import com.onesignal.logger.internal.LogTelemetryRemoteImpl + +/** + * Composition root for the logger module. Mirrors `OtelFactory`. + * + * Every dependency is injected (platform provider, HTTP sender, file store, logger), + * keeping the module free of any platform, networking, or storage coupling. This is + * the "decoupled but not so easy to use" trade-off — callers must supply the platform + * pieces, but in return the whole pipeline is multiplatform and testable. + */ +object LoggerFactory { + /** + * Creates a remote telemetry instance for shipping SDK log events over the + * network (batched, OTLP/JSON). + */ + fun createRemoteTelemetry( + platformProvider: ILoggerPlatformProvider, + httpSender: ILogHttpSender, + ): ILogTelemetryRemote = + LogTelemetryRemoteImpl( + platformProvider = platformProvider, + httpSender = httpSender, + topLevelFields = LogFieldsTopLevel(platformProvider), + perEventFields = LogFieldsPerEvent(platformProvider), + ) + + /** + * Creates a crash telemetry instance that persists records to local storage so + * they survive process death. + */ + fun createCrashLocalTelemetry( + platformProvider: ILoggerPlatformProvider, + fileStore: ILogFileStore, + ): ILogTelemetryCrash = + LogTelemetryCrashImpl( + fileStore = fileStore, + topLevelFields = LogFieldsTopLevel(platformProvider), + perEventFields = LogFieldsPerEvent(platformProvider), + ) + + /** Creates a crash reporter that writes a captured crash to [crashTelemetry]. */ + fun createCrashReporter( + crashTelemetry: ILogTelemetryCrash, + logger: ILogger, + ): ILogCrashReporter = LogCrashReporter(crashTelemetry, logger) + + /** + * Creates a crash uploader that ships disk-buffered crash reports on next launch. + */ + fun createCrashUploader( + platformProvider: ILoggerPlatformProvider, + remote: ILogTelemetryRemote, + fileStore: ILogFileStore, + logger: ILogger, + ): LogCrashUploader = LogCrashUploader(platformProvider, remote, fileStore, logger) +} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/attributes/LogFields.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/attributes/LogFields.kt new file mode 100644 index 000000000..c778c77b7 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/attributes/LogFields.kt @@ -0,0 +1,76 @@ +package com.onesignal.logger.attributes + +import com.onesignal.logger.ILoggerPlatformProvider +import com.onesignal.logger.internal.randomUuidString + +internal fun MutableMap.putIfValueNotNull(key: K, value: V?): MutableMap { + if (value != null) { + this[key] = value + } + return this +} + +/** + * Top-level / resource attributes. Included on every export and, per OTLP, attached + * to the `resource` rather than each record. Only values that cannot change during + * runtime belong here (they are fetched once and cached by the telemetry). + * + * Mirrors `OtelFieldsTopLevel` key-for-key. + */ +internal class LogFieldsTopLevel( + private val platformProvider: ILoggerPlatformProvider, +) { + suspend fun getAttributes(): Map { + val attributes: MutableMap = + mutableMapOf( + "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) + + return attributes.toMap() + } +} + +/** + * Per-event attributes. Recomputed for every record so each one reflects the current + * state (IDs, app state, enabled feature flags, etc.). + * + * Mirrors `OtelFieldsPerEvent` key-for-key. + */ +internal class LogFieldsPerEvent( + private val platformProvider: ILoggerPlatformProvider, +) { + fun getAttributes(): Map { + val attributes: MutableMap = mutableMapOf() + + attributes["log.record.uid"] = randomUuidString() + + attributes + .putIfValueNotNull("ossdk.app_id", platformProvider.appId) + .putIfValueNotNull("ossdk.onesignal_id", platformProvider.onesignalId) + .putIfValueNotNull("ossdk.push_subscription_id", platformProvider.pushSubscriptionId) + + attributes["app.state"] = platformProvider.appState + attributes["process.uptime"] = platformProvider.processUptime.toString() + attributes["thread.name"] = platformProvider.currentThreadName + + val enabledFlags = platformProvider.enabledFeatureFlags + if (enabledFlags.isNotEmpty()) { + attributes["ossdk.feature_flags"] = enabledFlags.sorted().joinToString(",") + } + + return attributes.toMap() + } +} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/crash/LogCrashReporter.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/crash/LogCrashReporter.kt new file mode 100644 index 000000000..118fcc04b --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/crash/LogCrashReporter.kt @@ -0,0 +1,44 @@ +package com.onesignal.logger.crash + +import com.onesignal.logger.CrashData +import com.onesignal.logger.ILogCrashReporter +import com.onesignal.logger.ILogTelemetryCrash +import com.onesignal.logger.ILogger +import com.onesignal.logger.LogRecord +import com.onesignal.logger.LogSeverity + +/** + * Persists a captured crash by emitting it to the crash (disk) telemetry sink and + * forcing a flush so it survives the imminent process death. + * + * Mirrors `OtelCrashReporter`, but takes a platform-neutral [CrashData] instead of a + * JVM `Thread`/`Throwable`. + */ +internal class LogCrashReporter( + private val crashTelemetry: ILogTelemetryCrash, + private val logger: ILogger, +) : ILogCrashReporter { + override suspend fun saveCrash(crash: CrashData) { + logger.info("LogCrashReporter: saving crash report for ${crash.exceptionType}") + + val body = crash.exceptionMessage.ifBlank { crash.exceptionType } + val record = + LogRecord( + severity = LogSeverity.FATAL, + body = body, + attributes = + mapOf( + "exception.message" to crash.exceptionMessage, + "exception.stacktrace" to crash.stacktrace, + "exception.type" to crash.exceptionType, + // Matches the top-level thread.name today, but kept distinct in + // case future refactors report from a different thread. + "ossdk.exception.thread.name" to crash.threadName, + ), + ) + + crashTelemetry.emit(record) + crashTelemetry.forceFlush() + logger.info("LogCrashReporter: crash report saved and flushed") + } +} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/crash/LogCrashUploader.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/crash/LogCrashUploader.kt new file mode 100644 index 000000000..cae83e45e --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/crash/LogCrashUploader.kt @@ -0,0 +1,67 @@ +package com.onesignal.logger.crash + +import com.onesignal.logger.ILogFileStore +import com.onesignal.logger.ILogTelemetryRemote +import com.onesignal.logger.ILogger +import com.onesignal.logger.ILoggerPlatformProvider +import kotlinx.coroutines.delay + +/** + * Reads locally-buffered crash reports and ships them to OneSignal on the next app + * start. Mirrors `OtelCrashUploader`, but reads our own simple disk format instead of + * OpenTelemetry's disk-buffering library. + * + * Usage: + * ```kotlin + * val uploader = LoggerFactory.createCrashUploader(provider, remote, fileStore, logger) + * scope.launch { uploader.start() } + * ``` + */ +class LogCrashUploader internal constructor( + private val platformProvider: ILoggerPlatformProvider, + private val remote: ILogTelemetryRemote, + private val fileStore: ILogFileStore, + private val logger: ILogger, +) { + /** + * Starts the uploader. No-op when remote logging is disabled (NONE / null level). + */ + suspend fun start() { + val remoteLogLevel = platformProvider.remoteLogLevel + if (remoteLogLevel == null || remoteLogLevel == "NONE") { + logger.info("LogCrashUploader: remote logging disabled (level: $remoteLogLevel)") + return + } + logger.info("LogCrashUploader: starting") + internalStart() + } + + /** + * Sends reports twice for the same reasons as the old uploader: + * 1. Send crash reports as soon as possible (app may crash again quickly). + * 2. A report from the previous crash may only become readable after + * [ILoggerPlatformProvider.minFileAgeForReadMillis] has elapsed (so we never + * read a file the crashing process may still have been writing). + */ + internal suspend fun internalStart() { + sendReports() + delay(platformProvider.minFileAgeForReadMillis) + sendReports() + } + + private suspend fun sendReports() { + val reports = fileStore.listReadable(platformProvider.minFileAgeForReadMillis) + for (report in reports) { + logger.debug("LogCrashUploader: sending crash report ${report.id}") + val success = remote.exportEncoded(report.bytes) + logger.debug("LogCrashUploader: done crash report ${report.id}, success: $success") + if (success) { + // Only delete on success so a failed upload is retried next launch. + fileStore.delete(report.id) + } else { + // Stop on first failure to avoid hammering a failing network. + break + } + } + } +} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogBatchProcessor.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogBatchProcessor.kt new file mode 100644 index 000000000..0b016a53f --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogBatchProcessor.kt @@ -0,0 +1,70 @@ +package com.onesignal.logger.internal + +import com.onesignal.logger.otlp.EncodableRecord +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withTimeoutOrNull + +/** + * Coroutine-based batch processor. Hand-rolled, dependency-light replacement for + * OpenTelemetry's `BatchLogRecordProcessor`. + * + * Records are buffered and exported when either the buffer reaches [maxBatchSize] + * or [scheduleDelayMillis] elapses. When the buffer exceeds [maxQueueSize], new + * records are dropped (back-pressure-free, never blocks the caller's pipeline). + */ +internal class LogBatchProcessor( + private val scope: CoroutineScope, + private val maxQueueSize: Int, + private val maxBatchSize: Int, + private val scheduleDelayMillis: Long, + private val onExport: suspend (List) -> Unit, +) { + private val mutex = Mutex() + private val buffer = ArrayList() + + // CONFLATED: a flush request that arrives while one is pending is coalesced. + private val flushSignal = Channel(Channel.CONFLATED) + + init { + scope.launch { + while (isActive) { + // Wake on either the schedule delay or an explicit size-triggered signal. + withTimeoutOrNull(scheduleDelayMillis) { flushSignal.receive() } + drainAndExport() + } + } + } + + suspend fun enqueue(record: EncodableRecord) { + val triggerFlush = + mutex.withLock { + if (buffer.size >= maxQueueSize) { + return // queue full — drop, matching BatchLogRecordProcessor semantics + } + buffer.add(record) + buffer.size >= maxBatchSize + } + if (triggerFlush) { + flushSignal.trySend(Unit) + } + } + + /** Exports everything currently buffered. */ + suspend fun flush() = drainAndExport() + + private suspend fun drainAndExport() { + val batch = + mutex.withLock { + if (buffer.isEmpty()) return + val copy = buffer.toList() + buffer.clear() + copy + } + onExport(batch) + } +} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogEndpoint.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogEndpoint.kt new file mode 100644 index 000000000..84952a9d3 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogEndpoint.kt @@ -0,0 +1,14 @@ +package com.onesignal.logger.internal + +/** + * Builds the log ingestion endpoint, mirroring the path the OpenTelemetry exporter + * used: `{apiBaseUrl}/sdk/log?app_id={appId}`. + */ +internal object LogEndpoint { + private const val LOG_PATH = "sdk/log" + + fun build(apiBaseUrl: String, appId: String): String { + val base = apiBaseUrl.trimEnd('/') + return "$base/$LOG_PATH?app_id=$appId" + } +} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryCrashImpl.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryCrashImpl.kt new file mode 100644 index 000000000..232ee2116 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryCrashImpl.kt @@ -0,0 +1,43 @@ +package com.onesignal.logger.internal + +import com.onesignal.logger.ILogFileStore +import com.onesignal.logger.ILogTelemetryCrash +import com.onesignal.logger.LogRecord +import com.onesignal.logger.attributes.LogFieldsPerEvent +import com.onesignal.logger.attributes.LogFieldsTopLevel +import com.onesignal.logger.otlp.EncodableRecord +import com.onesignal.logger.otlp.OtlpLogEncoder + +/** + * Crash telemetry sink: encodes each record to OTLP/JSON and writes it to the + * injected [ILogFileStore] immediately. This is what makes crash reports survive a + * process death — they are durably persisted before the process exits and shipped on + * the next launch by the crash uploader. + * + * Records are stored fully self-contained (resource attributes baked in at capture + * time) so the uploader can POST the stored bytes verbatim. + */ +internal class LogTelemetryCrashImpl( + private val fileStore: ILogFileStore, + private val topLevelFields: LogFieldsTopLevel, + private val perEventFields: LogFieldsPerEvent, +) : ILogTelemetryCrash { + override suspend fun emit(record: LogRecord) { + val resourceAttributes = topLevelFields.getAttributes() + val merged = perEventFields.getAttributes() + record.attributes + val encodable = + EncodableRecord( + severity = record.severity, + body = record.body, + attributes = merged, + timeUnixNanos = record.timestampNanos ?: epochNanosNow(), + ) + val bytes = OtlpLogEncoder.encode(resourceAttributes, listOf(encodable)) + fileStore.save(bytes) + } + + // Writes are synchronous in emit(); nothing is buffered, so flush is a no-op. + override suspend fun forceFlush() = Unit + + override fun shutdown() = Unit +} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryRemoteImpl.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryRemoteImpl.kt new file mode 100644 index 000000000..ed2d2cea6 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryRemoteImpl.kt @@ -0,0 +1,106 @@ +package com.onesignal.logger.internal + +import com.onesignal.logger.ILogHttpSender +import com.onesignal.logger.ILogTelemetryRemote +import com.onesignal.logger.ILoggerPlatformProvider +import com.onesignal.logger.LogHttpRequest +import com.onesignal.logger.LogRecord +import com.onesignal.logger.attributes.LogFieldsPerEvent +import com.onesignal.logger.attributes.LogFieldsTopLevel +import com.onesignal.logger.otlp.EncodableRecord +import com.onesignal.logger.otlp.OtlpLogEncoder +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Remote telemetry sink: batches records and ships them as OTLP/JSON over the + * injected [ILogHttpSender]. Resource (top-level) attributes are computed once and + * cached, mirroring the old SDK's resource caching. + */ +internal class LogTelemetryRemoteImpl( + private val platformProvider: ILoggerPlatformProvider, + private val httpSender: ILogHttpSender, + private val topLevelFields: LogFieldsTopLevel, + private val perEventFields: LogFieldsPerEvent, + private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), +) : ILogTelemetryRemote { + companion object { + private const val MAX_QUEUE_SIZE = 100 + private const val MAX_BATCH_SIZE = 100 + private const val SCHEDULE_DELAY_MILLIS = 1_000L + } + + private val endpoint: String by lazy { + LogEndpoint.build(platformProvider.apiBaseUrl, platformProvider.appIdForHeaders) + } + + private val headers: Map by lazy { + mapOf( + "SDK-Version" to "onesignal/${platformProvider.sdkBase}/${platformProvider.sdkBaseVersion}", + ) + } + + private val resourceMutex = Mutex() + private var cachedResourceAttributes: Map? = null + + private val batchProcessor = + LogBatchProcessor( + scope = scope, + maxQueueSize = MAX_QUEUE_SIZE, + maxBatchSize = MAX_BATCH_SIZE, + scheduleDelayMillis = SCHEDULE_DELAY_MILLIS, + onExport = ::exportBatch, + ) + + private suspend fun getResourceAttributes(): Map { + cachedResourceAttributes?.let { return it } + return resourceMutex.withLock { + cachedResourceAttributes ?: topLevelFields.getAttributes().also { cachedResourceAttributes = it } + } + } + + override suspend fun emit(record: LogRecord) { + val merged = perEventFields.getAttributes() + record.attributes + batchProcessor.enqueue( + EncodableRecord( + severity = record.severity, + body = record.body, + attributes = merged, + timeUnixNanos = record.timestampNanos ?: epochNanosNow(), + ), + ) + } + + private suspend fun exportBatch(records: List) { + val payload = OtlpLogEncoder.encode(getResourceAttributes(), records) + post(payload) + } + + override suspend fun exportEncoded(payload: ByteArray): Boolean = post(payload) + + private suspend fun post(payload: ByteArray): Boolean { + val response = + httpSender.send( + LogHttpRequest( + url = endpoint, + headers = headers, + contentType = OtlpLogEncoder.CONTENT_TYPE, + body = payload, + ), + ) + return response.success + } + + override suspend fun forceFlush() = batchProcessor.flush() + + override fun shutdown() { + // Best-effort: cancels the batch loop. Any sub-second buffered remote logs may + // be dropped — acceptable for continuous logging (crash records do not use this + // path). Callers wanting a guaranteed flush should call forceFlush() first. + scope.cancel() + } +} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/Platform.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/Platform.kt new file mode 100644 index 000000000..9bf5308a8 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/Platform.kt @@ -0,0 +1,12 @@ +package com.onesignal.logger.internal + +/** + * Current wall-clock time in nanoseconds since the Unix epoch. + * + * Resolution is platform-dependent (typically millisecond precision scaled to + * nanos); only used for OTLP `timeUnixNano` stamping. + */ +internal expect fun epochNanosNow(): Long + +/** A random UUID string (lowercase, hyphenated). Used for log record idempotency. */ +internal expect fun randomUuidString(): String diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpDtos.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpDtos.kt new file mode 100644 index 000000000..5f69f6e31 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpDtos.kt @@ -0,0 +1,65 @@ +package com.onesignal.logger.otlp + +import kotlinx.serialization.Serializable + +/** + * Minimal OTLP/JSON data model for the logs export request. + * + * This is a hand-rolled subset of `opentelemetry-proto`'s `ExportLogsServiceRequest`, + * sufficient for shipping string-valued log records. Keeping it tiny and explicit + * (rather than depending on the OpenTelemetry SDK) is what lets the same encoder run + * on every Kotlin target. + * + * Field names match the OTLP/JSON spec exactly (camelCase). uint64 fields are encoded + * as strings per the OTLP/JSON convention. + * + * See: https://opentelemetry.io/docs/specs/otlp/#otlphttp and + * https://github.com/open-telemetry/opentelemetry-proto + */ +@Serializable +internal data class OtlpExportLogsRequest( + val resourceLogs: List, +) + +@Serializable +internal data class OtlpResourceLogs( + val resource: OtlpResource, + val scopeLogs: List, +) + +@Serializable +internal data class OtlpResource( + val attributes: List, +) + +@Serializable +internal data class OtlpScopeLogs( + val scope: OtlpScope, + val logRecords: List, +) + +@Serializable +internal data class OtlpScope( + val name: String, +) + +@Serializable +internal data class OtlpLogRecord( + val timeUnixNano: String, + val observedTimeUnixNano: String, + val severityNumber: Int, + val severityText: String, + val body: OtlpAnyValue, + val attributes: List, +) + +@Serializable +internal data class OtlpKeyValue( + val key: String, + val value: OtlpAnyValue, +) + +@Serializable +internal data class OtlpAnyValue( + val stringValue: String, +) diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpLogEncoder.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpLogEncoder.kt new file mode 100644 index 000000000..f5d4b0cc1 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpLogEncoder.kt @@ -0,0 +1,71 @@ +package com.onesignal.logger.otlp + +import com.onesignal.logger.LogSeverity +import kotlinx.serialization.json.Json + +/** + * A record ready to be encoded: severity/body/timestamp plus the FULLY-merged + * attribute set (per-event + record-specific). Resource/top-level attributes are + * supplied separately to the encoder. + */ +internal data class EncodableRecord( + val severity: LogSeverity, + val body: String, + val attributes: Map, + val timeUnixNanos: Long, +) + +/** + * Encodes log records into OTLP/JSON bytes. + * + * This is the single seam that decides the wire format. The OneSignal ingestion + * endpoint historically received OTLP protobuf from the OpenTelemetry SDK; OTLP/JSON + * is part of the same spec and is dramatically simpler to produce on every Kotlin + * target. If the backend turns out to require protobuf, ONLY this class needs to + * change (swap [CONTENT_TYPE] and the body bytes) — nothing else in the pipeline. + */ +internal object OtlpLogEncoder { + const val CONTENT_TYPE = "application/json" + + private const val SCOPE_NAME = "OneSignalDeviceSDK" + + private val json = Json { + encodeDefaults = true + } + + fun encode( + resourceAttributes: Map, + records: List, + ): ByteArray { + val request = OtlpExportLogsRequest( + resourceLogs = listOf( + OtlpResourceLogs( + resource = OtlpResource(resourceAttributes.toKeyValues()), + scopeLogs = listOf( + OtlpScopeLogs( + scope = OtlpScope(SCOPE_NAME), + logRecords = records.map { it.toOtlp() }, + ), + ), + ), + ), + ) + return json.encodeToString(OtlpExportLogsRequest.serializer(), request).encodeToByteArray() + } + + private fun EncodableRecord.toOtlp(): OtlpLogRecord { + val time = timeUnixNanos.toString() + return OtlpLogRecord( + timeUnixNano = time, + observedTimeUnixNano = time, + severityNumber = severity.severityNumber, + severityText = severity.severityText, + body = OtlpAnyValue(body), + attributes = attributes.toKeyValues(), + ) + } + + private fun Map.toKeyValues(): List = + // Sort for deterministic output (stable payloads, easier testing/diffing). + entries.sortedBy { it.key }.map { OtlpKeyValue(it.key, OtlpAnyValue(it.value)) } +} diff --git a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogBatchProcessorTest.kt b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogBatchProcessorTest.kt new file mode 100644 index 000000000..08abb7342 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogBatchProcessorTest.kt @@ -0,0 +1,41 @@ +package com.onesignal.logger + +import com.onesignal.logger.internal.LogBatchProcessor +import com.onesignal.logger.otlp.EncodableRecord +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class LogBatchProcessorTest { + private fun rec(body: String) = + EncodableRecord(LogSeverity.INFO, body, emptyMap(), 1L) + + @Test + fun flushExportsBufferedRecordsAsSingleBatch() = runTest { + val exported = mutableListOf>() + // Very large schedule delay so only the explicit flush triggers an export. + val proc = LogBatchProcessor(backgroundScope, 100, 100, 100_000L) { exported.add(it) } + + proc.enqueue(rec("a")) + proc.enqueue(rec("b")) + proc.flush() + + assertEquals(1, exported.size) + assertEquals(2, exported[0].size) + } + + @Test + fun dropsRecordsWhenQueueIsFull() = runTest { + val exported = mutableListOf>() + val proc = LogBatchProcessor(backgroundScope, maxQueueSize = 2, maxBatchSize = 100, scheduleDelayMillis = 100_000L) { + exported.add(it) + } + + proc.enqueue(rec("a")) + proc.enqueue(rec("b")) + proc.enqueue(rec("c")) // dropped: queue already at capacity + proc.flush() + + assertEquals(2, exported[0].size) + } +} diff --git a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogCrashTest.kt b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogCrashTest.kt new file mode 100644 index 000000000..ab86a831b --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogCrashTest.kt @@ -0,0 +1,94 @@ +package com.onesignal.logger + +import com.onesignal.logger.attributes.LogFieldsPerEvent +import com.onesignal.logger.attributes.LogFieldsTopLevel +import com.onesignal.logger.internal.LogTelemetryRemoteImpl +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LogCrashTest { + private fun remote( + scope: kotlinx.coroutines.CoroutineScope, + provider: FakePlatformProvider, + http: FakeHttpSender, + ) = LogTelemetryRemoteImpl( + platformProvider = provider, + httpSender = http, + topLevelFields = LogFieldsTopLevel(provider), + perEventFields = LogFieldsPerEvent(provider), + scope = scope, + ) + + @Test + fun reporterSavesFatalRecordWithExceptionAttributes() = runTest { + val provider = FakePlatformProvider() + val store = FakeFileStore() + val crashTelemetry = LoggerFactory.createCrashLocalTelemetry(provider, store) + val reporter = LoggerFactory.createCrashReporter(crashTelemetry, RecordingLogger()) + + reporter.saveCrash( + CrashData( + threadName = "main", + exceptionType = "java.lang.NullPointerException", + exceptionMessage = "npe message", + stacktrace = "stack...", + ), + ) + + assertEquals(1, store.entries.size) + val json = store.entries[0].bytes.decodeToString() + assertTrue(json.contains("npe message")) + assertTrue(json.contains("java.lang.NullPointerException")) + assertTrue(json.contains("ossdk.exception.thread.name")) + } + + @Test + fun uploaderSendsReadableReportsAndDeletesOnSuccess() = runTest { + val provider = FakePlatformProvider(minFileAgeForReadMillis = 0) + val store = FakeFileStore() + store.seed("f1", "payload1".encodeToByteArray(), ageMillis = Long.MAX_VALUE) + store.seed("f2", "payload2".encodeToByteArray(), ageMillis = Long.MAX_VALUE) + val http = FakeHttpSender() + val uploader = + LoggerFactory.createCrashUploader(provider, remote(backgroundScope, provider, http), store, RecordingLogger()) + + uploader.start() + + assertEquals(2, http.sentRequests.size) + assertTrue("f1" in store.deletedIds) + assertTrue("f2" in store.deletedIds) + } + + @Test + fun uploaderStopsOnFailureAndKeepsReports() = runTest { + val provider = FakePlatformProvider() + val store = FakeFileStore() + store.seed("f1", "p1".encodeToByteArray(), ageMillis = Long.MAX_VALUE) + store.seed("f2", "p2".encodeToByteArray(), ageMillis = Long.MAX_VALUE) + val http = FakeHttpSender(defaultResponse = LogHttpResponse(success = false, statusCode = 500)) + val uploader = + LoggerFactory.createCrashUploader(provider, remote(backgroundScope, provider, http), store, RecordingLogger()) + + uploader.start() + + // Two passes (start sends, then again after the read-age delay), each stops at f1. + assertEquals(2, http.sentRequests.size) + assertTrue(store.deletedIds.isEmpty()) + } + + @Test + fun uploaderNoOpWhenRemoteLoggingDisabled() = runTest { + val provider = FakePlatformProvider(remoteLogLevel = "NONE") + val store = FakeFileStore() + store.seed("f1", "p".encodeToByteArray(), ageMillis = Long.MAX_VALUE) + val http = FakeHttpSender() + val uploader = + LoggerFactory.createCrashUploader(provider, remote(backgroundScope, provider, http), store, RecordingLogger()) + + uploader.start() + + assertEquals(0, http.sentRequests.size) + } +} diff --git a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogFieldsTest.kt b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogFieldsTest.kt new file mode 100644 index 000000000..0be767d95 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogFieldsTest.kt @@ -0,0 +1,48 @@ +package com.onesignal.logger + +import com.onesignal.logger.attributes.LogFieldsPerEvent +import com.onesignal.logger.attributes.LogFieldsTopLevel +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LogFieldsTest { + @Test + fun topLevelIncludesExpectedKeysAndOmitsNullWrapper() = runTest { + val provider = FakePlatformProvider() + val attrs = LogFieldsTopLevel(provider).getAttributes() + + assertEquals("install-abc", attrs["ossdk.install_id"]) + assertEquals("android", attrs["ossdk.sdk_base"]) + assertEquals("Android", attrs["os.name"]) + assertFalse(attrs.containsKey("ossdk.sdk_wrapper")) + } + + @Test + fun perEventIncludesDynamicValuesAndUniqueRecordId() { + val provider = FakePlatformProvider() + val fields = LogFieldsPerEvent(provider) + + val a = fields.getAttributes() + val b = fields.getAttributes() + + assertEquals("app-123", a["ossdk.app_id"]) + assertEquals("foreground", a["app.state"]) + assertEquals("1234", a["process.uptime"]) + assertEquals("test-thread", a["thread.name"]) + // record uid must be unique per event + assertTrue(a["log.record.uid"] != b["log.record.uid"]) + } + + @Test + fun featureFlagsAreSortedCsvAndOmittedWhenEmpty() { + val provider = FakePlatformProvider(enabledFeatureFlags = listOf("zeta", "alpha")) + val attrs = LogFieldsPerEvent(provider).getAttributes() + assertEquals("alpha,zeta", attrs["ossdk.feature_flags"]) + + val empty = LogFieldsPerEvent(FakePlatformProvider()).getAttributes() + assertFalse(empty.containsKey("ossdk.feature_flags")) + } +} diff --git a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogTelemetryTest.kt b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogTelemetryTest.kt new file mode 100644 index 000000000..e103d45a2 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogTelemetryTest.kt @@ -0,0 +1,84 @@ +package com.onesignal.logger + +import com.onesignal.logger.attributes.LogFieldsPerEvent +import com.onesignal.logger.attributes.LogFieldsTopLevel +import com.onesignal.logger.internal.LogTelemetryCrashImpl +import com.onesignal.logger.internal.LogTelemetryRemoteImpl +import kotlinx.coroutines.test.runTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class LogTelemetryTest { + private fun remote( + scope: kotlinx.coroutines.CoroutineScope, + provider: FakePlatformProvider, + http: FakeHttpSender, + ) = LogTelemetryRemoteImpl( + platformProvider = provider, + httpSender = http, + topLevelFields = LogFieldsTopLevel(provider), + perEventFields = LogFieldsPerEvent(provider), + scope = scope, + ) + + @Test + fun emitThenForceFlushPostsOtlpJsonToCorrectEndpoint() = runTest { + val provider = FakePlatformProvider() + val http = FakeHttpSender() + val telemetry = remote(backgroundScope, provider, http) + + telemetry.emit(LogRecord(LogSeverity.ERROR, "hello", mapOf("custom" to "v"))) + telemetry.forceFlush() + + assertEquals(1, http.sentRequests.size) + val req = http.sentRequests[0] + assertEquals("https://api.onesignal.com/sdk/log?app_id=app-123", req.url) + assertEquals("application/json", req.contentType) + assertEquals("onesignal/android/5.9.5", req.headers["SDK-Version"]) + + val body = http.lastBodyAsString() + assertTrue(body.contains("hello")) + assertTrue(body.contains("custom")) + // resource attribute attached + assertTrue(body.contains("ossdk.install_id")) + } + + @Test + fun exportEncodedPostsRawBytes() = runTest { + val provider = FakePlatformProvider() + val http = FakeHttpSender() + val telemetry = remote(backgroundScope, provider, http) + + val ok = telemetry.exportEncoded("raw-bytes".encodeToByteArray()) + + assertTrue(ok) + assertEquals("raw-bytes", http.lastBodyAsString()) + } + + @Test + fun exportEncodedReturnsFalseOnHttpFailure() = runTest { + val provider = FakePlatformProvider() + val http = FakeHttpSender(defaultResponse = LogHttpResponse(success = false, statusCode = 500)) + val telemetry = remote(backgroundScope, provider, http) + + assertFalse(telemetry.exportEncoded("x".encodeToByteArray())) + } + + @Test + fun crashTelemetryWritesEncodedRecordToFileStore() = runTest { + val provider = FakePlatformProvider() + val store = FakeFileStore() + val telemetry = + LogTelemetryCrashImpl(store, LogFieldsTopLevel(provider), LogFieldsPerEvent(provider)) + + telemetry.emit(LogRecord(LogSeverity.FATAL, "crash!", mapOf("exception.type" to "X"))) + + assertEquals(1, store.entries.size) + val json = store.entries[0].bytes.decodeToString() + assertTrue(json.contains("crash!")) + assertTrue(json.contains("exception.type")) + assertTrue(json.contains("ossdk.install_id")) + } +} diff --git a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/OtlpLogEncoderTest.kt b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/OtlpLogEncoderTest.kt new file mode 100644 index 000000000..5d389b2a8 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/OtlpLogEncoderTest.kt @@ -0,0 +1,70 @@ +package com.onesignal.logger + +import com.onesignal.logger.otlp.EncodableRecord +import com.onesignal.logger.otlp.OtlpLogEncoder +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonArray +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class OtlpLogEncoderTest { + @Test + fun encodesValidOtlpJsonStructure() { + val bytes = + OtlpLogEncoder.encode( + resourceAttributes = mapOf("ossdk.install_id" to "abc", "os.name" to "Android"), + records = + listOf( + EncodableRecord( + severity = LogSeverity.ERROR, + body = "boom", + attributes = mapOf("log.level" to "ERROR", "log.message" to "boom"), + timeUnixNanos = 1700000000000000000L, + ), + ), + ) + + val root = Json.parseToJsonElement(bytes.decodeToString()).jsonObject + val resourceLogs = root["resourceLogs"]!!.jsonArray + assertEquals(1, resourceLogs.size) + + val resource = resourceLogs[0].jsonObject["resource"]!!.jsonObject + val resourceAttrs = resource["attributes"]!!.jsonArray + // os.name sorts before ossdk.install_id + assertEquals("os.name", resourceAttrs[0].jsonObject["key"]!!.jsonPrimitive.content) + + val scopeLogs = resourceLogs[0].jsonObject["scopeLogs"]!!.jsonArray + val scope = scopeLogs[0].jsonObject["scope"]!!.jsonObject + assertEquals("OneSignalDeviceSDK", scope["name"]!!.jsonPrimitive.content) + + val record = scopeLogs[0].jsonObject["logRecords"]!!.jsonArray[0].jsonObject + assertEquals("1700000000000000000", record["timeUnixNano"]!!.jsonPrimitive.content) + assertEquals(17, record["severityNumber"]!!.jsonPrimitive.content.toInt()) + assertEquals("ERROR", record["severityText"]!!.jsonPrimitive.content) + assertEquals("boom", record["body"]!!.jsonObject["stringValue"]!!.jsonPrimitive.content) + } + + @Test + fun severityNumbersMatchOtelScheme() { + assertEquals(1, LogSeverity.TRACE.severityNumber) + assertEquals(5, LogSeverity.DEBUG.severityNumber) + assertEquals(9, LogSeverity.INFO.severityNumber) + assertEquals(13, LogSeverity.WARN.severityNumber) + assertEquals(17, LogSeverity.ERROR.severityNumber) + assertEquals(21, LogSeverity.FATAL.severityNumber) + } + + @Test + fun verboseMapsToTrace() { + assertEquals(LogSeverity.TRACE, LogSeverity.fromLevelName("VERBOSE")) + assertEquals(LogSeverity.INFO, LogSeverity.fromLevelName("nonsense")) + } + + @Test + fun contentTypeIsJson() { + assertTrue(OtlpLogEncoder.CONTENT_TYPE.contains("json")) + } +} diff --git a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/TestFakes.kt b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/TestFakes.kt new file mode 100644 index 000000000..c85f420ef --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/TestFakes.kt @@ -0,0 +1,93 @@ +package com.onesignal.logger + +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +/** + * Simple in-memory fakes used across the logger module's common tests. + */ + +internal class FakePlatformProvider( + override var appId: String? = "app-123", + override var onesignalId: String? = "os-456", + override var pushSubscriptionId: String? = "push-789", + override var appState: String = "foreground", + override var processUptime: Long = 1234L, + override var currentThreadName: String = "test-thread", + override var enabledFeatureFlags: List = emptyList(), + override var crashStoragePath: String = "/tmp/onesignal/crashes", + override var minFileAgeForReadMillis: Long = 5_000L, + override var isRemoteLoggingEnabled: Boolean = true, + override var remoteLogLevel: String? = "ERROR", + override var isExporterLoggingEnabled: Boolean = false, + override var apiBaseUrl: String = "https://api.onesignal.com/", +) : ILoggerPlatformProvider { + var installId: String = "install-abc" + + override suspend fun getInstallId(): String = installId + override val sdkBase: String = "android" + override val sdkBaseVersion: String = "5.9.5" + override val appPackageId: String = "com.example.app" + override val appVersion: String = "1.0.0" + override val deviceManufacturer: String = "TestCo" + override val deviceModel: String = "Pixel-Test" + override val osName: String = "Android" + override val osVersion: String = "14" + override val osBuildId: String = "BUILD123" + override val sdkWrapper: String? = null + override val sdkWrapperVersion: String? = null + override val appIdForHeaders: String get() = appId ?: "" +} + +internal class FakeHttpSender( + var responses: ArrayDeque = ArrayDeque(), + var defaultResponse: LogHttpResponse = LogHttpResponse(success = true, statusCode = 200), +) : ILogHttpSender { + private val mutex = Mutex() + val sentRequests = mutableListOf() + + override suspend fun send(request: LogHttpRequest): LogHttpResponse { + mutex.withLock { sentRequests.add(request) } + return if (responses.isNotEmpty()) responses.removeFirst() else defaultResponse + } + + fun lastBodyAsString(): String = sentRequests.last().body.decodeToString() +} + +internal class FakeFileStore : ILogFileStore { + data class Entry(val id: String, val bytes: ByteArray, val ageMillis: Long) + + val entries = mutableListOf() + val deletedIds = mutableListOf() + private var counter = 0 + + /** Age assigned to records saved via [save]; tests can tweak per scenario. */ + var savedAgeMillis: Long = Long.MAX_VALUE + + override fun save(bytes: ByteArray) { + entries.add(Entry("file-${counter++}", bytes, savedAgeMillis)) + } + + fun seed(id: String, bytes: ByteArray, ageMillis: Long) { + entries.add(Entry(id, bytes, ageMillis)) + } + + override fun listReadable(minAgeMillis: Long): List = + entries + .filter { it.ageMillis >= minAgeMillis && it.id !in deletedIds } + .map { StoredLogFile(it.id, it.bytes) } + + override fun delete(id: String) { + deletedIds.add(id) + entries.removeAll { it.id == id } + } +} + +internal class RecordingLogger : ILogger { + val messages = mutableListOf() + + override fun error(message: String) { messages.add("E:$message") } + override fun warn(message: String) { messages.add("W:$message") } + override fun info(message: String) { messages.add("I:$message") } + override fun debug(message: String) { messages.add("D:$message") } +} diff --git a/OneSignalSDK/onesignal/logger/src/iosMain/kotlin/com/onesignal/logger/internal/Platform.ios.kt b/OneSignalSDK/onesignal/logger/src/iosMain/kotlin/com/onesignal/logger/internal/Platform.ios.kt new file mode 100644 index 000000000..7b934a6d0 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/iosMain/kotlin/com/onesignal/logger/internal/Platform.ios.kt @@ -0,0 +1,11 @@ +package com.onesignal.logger.internal + +import platform.Foundation.NSDate +import platform.Foundation.NSUUID +import platform.Foundation.timeIntervalSince1970 + +internal actual fun epochNanosNow(): Long = (NSDate().timeIntervalSince1970 * NANOS_PER_SECOND).toLong() + +internal actual fun randomUuidString(): String = NSUUID().UUIDString().lowercase() + +private const val NANOS_PER_SECOND = 1_000_000_000.0 diff --git a/OneSignalSDK/settings.gradle b/OneSignalSDK/settings.gradle index e5a1a34ad..6788ccb87 100644 --- a/OneSignalSDK/settings.gradle +++ b/OneSignalSDK/settings.gradle @@ -16,6 +16,7 @@ gradle.rootProject { 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:logger')).using(project(':OneSignal:logger')) } } } @@ -32,3 +33,4 @@ include ':OneSignal:location' include ':OneSignal:notifications' include ':OneSignal:testhelpers' include ':OneSignal:otel' +include ':OneSignal:logger' From 674e4cae0d36b6003b280ec7e2e05375b3da83a5 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 21 Jul 2026 14:04:45 +0530 Subject: [PATCH 02/15] feat(logger): [SDK-4835] send OTLP/protobuf and match otel batch, limits, and background I/O Switch the KMP logger module's wire format from OTLP/JSON to OTLP/protobuf (application/x-protobuf) via a dependency-free ProtoWriter, so the ingestion endpoint receives byte-compatible payloads with the old otel exporter. - Encode via hand-rolled ProtoWriter; drop kotlinx.serialization + OtlpDtos. - Port otel LogLimits: cap log-record attributes at 128 and truncate values to 32,000 chars (bounds long exception.stacktrace). - Make ILogFileStore.listReadable/delete suspend; run file I/O off-thread. - Route OneSignalLogHttpSender diagnostics through ILogger, gated on the exporter-logging toggle instead of unconditional logcat. - Add independent protobuf decode tests (ProtoTestReader) and limit tests. - Fix stale OTLP/JSON doc comments. Co-authored-by: Cursor --- .../crash/OneSignalCrashUploaderWrapper.kt | 6 +- .../logging/logger/LoggerModuleSwitch.kt | 2 +- .../logger/android/AndroidLogCrashHandler.kt | 26 ++- .../logging/logger/android/FileLogStore.kt | 45 +++--- .../logger/android/OneSignalLogHttpSender.kt | 29 +++- .../internal/LoggerLifecycleManager.kt | 2 +- OneSignalSDK/onesignal/logger/build.gradle | 3 - .../com/onesignal/logger/ILogFileStore.kt | 19 ++- .../com/onesignal/logger/ILogHttpSender.kt | 4 +- .../com/onesignal/logger/LoggerFactory.kt | 2 +- .../logger/internal/LogTelemetryCrashImpl.kt | 2 +- .../logger/internal/LogTelemetryRemoteImpl.kt | 2 +- .../com/onesignal/logger/otlp/OtlpDtos.kt | 65 -------- .../onesignal/logger/otlp/OtlpLogEncoder.kt | 148 +++++++++++++----- .../com/onesignal/logger/otlp/ProtoWriter.kt | 84 ++++++++++ .../com/onesignal/logger/LogTelemetryTest.kt | 26 +-- .../onesignal/logger/OtlpLogEncoderTest.kt | 118 +++++++++++--- .../com/onesignal/logger/ProtoTestReader.kt | 71 +++++++++ .../kotlin/com/onesignal/logger/TestFakes.kt | 4 +- 19 files changed, 481 insertions(+), 177 deletions(-) delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpDtos.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/ProtoWriter.kt create mode 100644 OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/ProtoTestReader.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 8569f6a7b..65f251437 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 @@ -54,9 +54,11 @@ internal class OneSignalCrashUploaderWrapper( private val loggerUploader by lazy { val platformProvider = createAndroidLoggerPlatformProvider(applicationService.appContext) { featureManager } - val remote = LoggerFactory.createRemoteTelemetry(platformProvider, OneSignalLogHttpSender()) + val logger = AndroidLogger() + val httpSender = OneSignalLogHttpSender(logger) { platformProvider.isExporterLoggingEnabled } + val remote = LoggerFactory.createRemoteTelemetry(platformProvider, httpSender) val fileStore = FileLogStore(platformProvider.crashStoragePath) - LoggerFactory.createCrashUploader(platformProvider, remote, fileStore, AndroidLogger()) + LoggerFactory.createCrashUploader(platformProvider, remote, fileStore, logger) } @Suppress("TooGenericExceptionCaught") diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt index 9cd971219..f02d9e8a1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt @@ -14,5 +14,5 @@ package com.onesignal.debug.internal.logging.logger * switch) can be removed along with the `:otel` module. */ internal object LoggerModuleSwitch { - const val USE_LOGGER_MODULE = false + const val USE_LOGGER_MODULE = true } 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 a57415f59..1cf7c8bc8 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 @@ -60,7 +60,7 @@ internal class AndroidLogCrashHandler( throwable.javaClass.simpleName.contains("ApplicationNotResponding", ignoreCase = true) || throwable.message?.contains("Application Not Responding", ignoreCase = true) == true - if (!isAnr && !isOneSignalAtFault(throwable.stackTrace)) { + if (!isAnr && !isOneSignalAtFault(throwable)) { logger.debug("AndroidLogCrashHandler: crash not OneSignal-related, delegating") existingHandler?.uncaughtException(thread, throwable) return @@ -87,3 +87,27 @@ internal fun Throwable.toCrashData(thread: Thread): CrashData = /** True when any frame originates from OneSignal SDK code. */ internal fun isOneSignalAtFault(stackTrace: Array): Boolean = stackTrace.any { it.className.startsWith("com.onesignal") } + +/** + * True when OneSignal appears anywhere in the throwable graph — the throwable + * itself, its [Throwable.cause] chain, or any [Throwable.getSuppressed] entries. + * + * This is broader than inspecting only the top-level frames: framework wrappers + * (e.g. `RuntimeException: Unable to create application`) and SDK init failures + * that re-throw a generic exception bury the real OneSignal frames one or more + * levels down as a cause or suppressed exception. A BFS with an identity-based + * visited set keeps us safe against cyclic cause chains. + */ +internal fun isOneSignalAtFault(throwable: Throwable): Boolean { + val visited = java.util.Collections.newSetFromMap(java.util.IdentityHashMap()) + val queue = ArrayDeque() + queue.add(throwable) + while (queue.isNotEmpty()) { + val current = queue.removeFirst() + if (!visited.add(current)) continue + if (isOneSignalAtFault(current.stackTrace)) return true + current.cause?.let { queue.add(it) } + current.suppressed.forEach { queue.add(it) } + } + return false +} 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 b5dcf9c6d..189ad24c0 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 @@ -2,6 +2,8 @@ package com.onesignal.debug.internal.logging.logger.android import com.onesignal.logger.ILogFileStore import com.onesignal.logger.StoredLogFile +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext import java.io.File import java.util.UUID @@ -43,30 +45,33 @@ internal class FileLogStore( } @Suppress("TooGenericExceptionCaught", "SwallowedException") - override fun listReadable(minAgeMillis: Long): List { - return try { - val now = System.currentTimeMillis() - rootDir.listFiles { file -> file.isFile && file.name.endsWith(FILE_SUFFIX) } - ?.filter { now - it.lastModified() >= minAgeMillis } - ?.mapNotNull { file -> - try { - StoredLogFile(id = file.name, bytes = file.readBytes()) - } catch (t: Throwable) { - null + override suspend fun listReadable(minAgeMillis: Long): List = + withContext(Dispatchers.IO) { + try { + val now = System.currentTimeMillis() + rootDir.listFiles { file -> file.isFile && file.name.endsWith(FILE_SUFFIX) } + ?.filter { now - it.lastModified() >= minAgeMillis } + ?.mapNotNull { file -> + try { + StoredLogFile(id = file.name, bytes = file.readBytes()) + } catch (t: Throwable) { + null + } } - } - ?: emptyList() - } catch (t: Throwable) { - emptyList() + ?: emptyList() + } catch (t: Throwable) { + emptyList() + } } - } @Suppress("TooGenericExceptionCaught", "SwallowedException") - override fun delete(id: String) { - try { - File(rootDir, id).delete() - } catch (t: Throwable) { - // best-effort + override suspend fun delete(id: String) { + withContext(Dispatchers.IO) { + try { + File(rootDir, id).delete() + } catch (t: Throwable) { + // best-effort + } } } } 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 25946cd3f..bd0e8b7a6 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 @@ -1,6 +1,7 @@ package com.onesignal.debug.internal.logging.logger.android import com.onesignal.logger.ILogHttpSender +import com.onesignal.logger.ILogger import com.onesignal.logger.LogHttpRequest import com.onesignal.logger.LogHttpResponse import kotlinx.coroutines.Dispatchers @@ -14,13 +15,22 @@ import java.net.URL * The `logger` module deliberately has no networking dependency, so the platform * supplies the transport. This keeps the module pure-Kotlin/multiplatform while * routing all SDK log traffic through a standard, well-understood HTTP client. + * + * 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 + * logcat noise in production. */ -internal class OneSignalLogHttpSender : ILogHttpSender { +internal class OneSignalLogHttpSender( + private val logger: ILogger, + private val isDiagnosticsEnabled: () -> Boolean = { false }, +) : ILogHttpSender { companion object { private const val CONNECT_TIMEOUT_MS = 10_000 private const val READ_TIMEOUT_MS = 10_000 private const val HTTP_OK_MIN = 200 private const val HTTP_OK_MAX = 299 + private const val MAX_LOGGED_BODY_CHARS = 500 } @Suppress("TooGenericExceptionCaught") @@ -42,11 +52,24 @@ internal class OneSignalLogHttpSender : ILogHttpSender { val code = connection.responseCode val success = code in HTTP_OK_MIN..HTTP_OK_MAX if (!success) { - // Drain the error stream so the connection can be reused/closed cleanly. - connection.errorStream?.use { it.readBytes() } + // Always drain the error stream so the connection can be reused/closed cleanly; + // only surface it when diagnostics are enabled. + val errorBody = connection.errorStream?.use { String(it.readBytes()) } + if (isDiagnosticsEnabled()) { + logger.warn( + "OneSignalLogHttpSender: POST ${request.url} -> $code " + + "(ct=${request.contentType}, ${request.body.size}B) " + + "body=${errorBody?.take(MAX_LOGGED_BODY_CHARS)}", + ) + } + } else if (isDiagnosticsEnabled()) { + logger.debug("OneSignalLogHttpSender: POST ${request.url} -> $code OK (${request.body.size}B)") } LogHttpResponse(success = success, statusCode = code) } catch (t: Throwable) { + if (isDiagnosticsEnabled()) { + logger.warn("OneSignalLogHttpSender: POST ${request.url} failed: ${t::class.simpleName}: ${t.message}") + } LogHttpResponse(success = false, statusCode = -1, message = t.message) } finally { connection?.disconnect() 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 26b53411b..5f6dcc31d 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,7 +43,7 @@ internal class LoggerLifecycleManager( } private val logger = AndroidLogger() - private val httpSender = OneSignalLogHttpSender() + private val httpSender = OneSignalLogHttpSender(logger) { platformProvider.isExporterLoggingEnabled } private val fileStore: FileLogStore by lazy { FileLogStore(platformProvider.crashStoragePath) } diff --git a/OneSignalSDK/onesignal/logger/build.gradle b/OneSignalSDK/onesignal/logger/build.gradle index 60deed535..d8ed7e55a 100644 --- a/OneSignalSDK/onesignal/logger/build.gradle +++ b/OneSignalSDK/onesignal/logger/build.gradle @@ -1,7 +1,6 @@ plugins { id 'org.jetbrains.kotlin.multiplatform' id 'com.android.library' - id 'org.jetbrains.kotlin.plugin.serialization' id 'com.diffplug.spotless' } @@ -39,14 +38,12 @@ kotlin { commonMain { dependencies { implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" - implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationJsonVersion" } } commonTest { dependencies { implementation kotlin('test') implementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" - implementation "org.jetbrains.kotlinx:kotlinx-serialization-json:$kotlinxSerializationJsonVersion" } } } diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogFileStore.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogFileStore.kt index 39bd4f3a6..b3a38e4ef 100644 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogFileStore.kt +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogFileStore.kt @@ -24,23 +24,34 @@ data class StoredLogFile( * Platform-agnostic durable store for crash records. * * Replaces OpenTelemetry's `disk-buffering` contrib library. The on-disk format is - * entirely owned by this module (each record is one encoded OTLP/JSON payload), so + * entirely owned by this module (each record is one encoded OTLP/protobuf payload), so * the same logic works on any platform that can provide simple file primitives. * * Implementations must be safe to call from a crash path (i.e. cheap, no heavy * initialization). */ interface ILogFileStore { - /** Persists [bytes] under a newly generated entry. */ + /** + * Persists [bytes] under a newly generated entry. + * + * Intentionally NOT a suspend function: the only caller is the crash sink, which + * runs on the crashing thread inside the uncaught-exception handler and must + * complete the write synchronously before the process dies. Implementations must + * keep it cheap and never offload to another thread/queue on this path. + */ fun save(bytes: ByteArray) /** * Returns all readable entries whose age is at least [minAgeMillis]. The age * gate mirrors `minFileAgeForReadMillis` from the old pipeline: it guarantees * we never read a file that may still be mid-write from the crashing process. + * + * Suspends so implementations can perform the (blocking) directory scan and reads + * on a background dispatcher — keeping the shared upload pipeline off the caller's + * thread on every platform, and bridging to Swift `async` on iOS. */ - fun listReadable(minAgeMillis: Long): List + suspend fun listReadable(minAgeMillis: Long): List /** Deletes the entry with the given [id]. Safe to call if already gone. */ - fun delete(id: String) + suspend fun delete(id: String) } diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogHttpSender.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogHttpSender.kt index 2d0a4b584..3d80014f5 100644 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogHttpSender.kt +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogHttpSender.kt @@ -1,8 +1,8 @@ package com.onesignal.logger /** - * Outbound HTTP request for log export. Body is already-encoded bytes (e.g. OTLP - * JSON) with the matching [contentType]. + * Outbound HTTP request for log export. Body is already-encoded bytes (OTLP + * protobuf) with the matching [contentType]. */ data class LogHttpRequest( val url: String, diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LoggerFactory.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LoggerFactory.kt index 1568dfe3a..591ce08a8 100644 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LoggerFactory.kt +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LoggerFactory.kt @@ -18,7 +18,7 @@ import com.onesignal.logger.internal.LogTelemetryRemoteImpl object LoggerFactory { /** * Creates a remote telemetry instance for shipping SDK log events over the - * network (batched, OTLP/JSON). + * network (batched, OTLP/protobuf). */ fun createRemoteTelemetry( platformProvider: ILoggerPlatformProvider, diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryCrashImpl.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryCrashImpl.kt index 232ee2116..7e7afea8a 100644 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryCrashImpl.kt +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryCrashImpl.kt @@ -9,7 +9,7 @@ import com.onesignal.logger.otlp.EncodableRecord import com.onesignal.logger.otlp.OtlpLogEncoder /** - * Crash telemetry sink: encodes each record to OTLP/JSON and writes it to the + * Crash telemetry sink: encodes each record to OTLP/protobuf and writes it to the * injected [ILogFileStore] immediately. This is what makes crash reports survive a * process death — they are durably persisted before the process exits and shipped on * the next launch by the crash uploader. diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryRemoteImpl.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryRemoteImpl.kt index ed2d2cea6..02a9563b3 100644 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryRemoteImpl.kt +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryRemoteImpl.kt @@ -17,7 +17,7 @@ import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock /** - * Remote telemetry sink: batches records and ships them as OTLP/JSON over the + * Remote telemetry sink: batches records and ships them as OTLP/protobuf over the * injected [ILogHttpSender]. Resource (top-level) attributes are computed once and * cached, mirroring the old SDK's resource caching. */ diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpDtos.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpDtos.kt deleted file mode 100644 index 5f69f6e31..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpDtos.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.onesignal.logger.otlp - -import kotlinx.serialization.Serializable - -/** - * Minimal OTLP/JSON data model for the logs export request. - * - * This is a hand-rolled subset of `opentelemetry-proto`'s `ExportLogsServiceRequest`, - * sufficient for shipping string-valued log records. Keeping it tiny and explicit - * (rather than depending on the OpenTelemetry SDK) is what lets the same encoder run - * on every Kotlin target. - * - * Field names match the OTLP/JSON spec exactly (camelCase). uint64 fields are encoded - * as strings per the OTLP/JSON convention. - * - * See: https://opentelemetry.io/docs/specs/otlp/#otlphttp and - * https://github.com/open-telemetry/opentelemetry-proto - */ -@Serializable -internal data class OtlpExportLogsRequest( - val resourceLogs: List, -) - -@Serializable -internal data class OtlpResourceLogs( - val resource: OtlpResource, - val scopeLogs: List, -) - -@Serializable -internal data class OtlpResource( - val attributes: List, -) - -@Serializable -internal data class OtlpScopeLogs( - val scope: OtlpScope, - val logRecords: List, -) - -@Serializable -internal data class OtlpScope( - val name: String, -) - -@Serializable -internal data class OtlpLogRecord( - val timeUnixNano: String, - val observedTimeUnixNano: String, - val severityNumber: Int, - val severityText: String, - val body: OtlpAnyValue, - val attributes: List, -) - -@Serializable -internal data class OtlpKeyValue( - val key: String, - val value: OtlpAnyValue, -) - -@Serializable -internal data class OtlpAnyValue( - val stringValue: String, -) diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpLogEncoder.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpLogEncoder.kt index f5d4b0cc1..56ef6681c 100644 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpLogEncoder.kt +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpLogEncoder.kt @@ -1,7 +1,6 @@ package com.onesignal.logger.otlp import com.onesignal.logger.LogSeverity -import kotlinx.serialization.json.Json /** * A record ready to be encoded: severity/body/timestamp plus the FULLY-merged @@ -16,56 +15,129 @@ internal data class EncodableRecord( ) /** - * Encodes log records into OTLP/JSON bytes. + * Encodes log records into **OTLP/protobuf** bytes (`Content-Type: application/x-protobuf`). * - * This is the single seam that decides the wire format. The OneSignal ingestion - * endpoint historically received OTLP protobuf from the OpenTelemetry SDK; OTLP/JSON - * is part of the same spec and is dramatically simpler to produce on every Kotlin - * target. If the backend turns out to require protobuf, ONLY this class needs to - * change (swap [CONTENT_TYPE] and the body bytes) — nothing else in the pipeline. + * This deliberately matches what the OpenTelemetry Java SDK's `OtlpHttpLogRecordExporter` + * put on the wire (binary protobuf is its default; JSON is opt-in via `exportAsJson()`, + * which the old `otel` module never called). Producing the same wire format here means + * the OneSignal ingestion endpoint receives byte-for-byte the same kind of payload it + * always has — the transport swapped from the OpenTelemetry SDK to this module, but the + * bytes did not. + * + * Only the tiny subset of `opentelemetry-proto`'s `ExportLogsServiceRequest` needed for + * string-valued log records is implemented, hand-rolled via [ProtoWriter] so it runs on + * every Kotlin target with no dependency. Field numbers below come from + * `opentelemetry/proto/logs/v1/logs.proto` and `.../common/v1/common.proto`. */ internal object OtlpLogEncoder { - const val CONTENT_TYPE = "application/json" + const val CONTENT_TYPE = "application/x-protobuf" private const val SCOPE_NAME = "OneSignalDeviceSDK" - private val json = Json { - encodeDefaults = true - } + // Mirrors the old otel module's LogLimits (OtelConfigShared.LogLimitsConfig): the SDK + // capped each log record at 128 attributes and truncated attribute values to 32,000 + // chars (the exception.stacktrace value can be very long). Applied to log-record + // attributes only — resource attributes and the body are left untouched, exactly as + // LogLimits scoped it. + private const val MAX_ATTRIBUTE_COUNT = 128 + private const val MAX_ATTRIBUTE_VALUE_LENGTH = 32_000 + + // ExportLogsServiceRequest + private const val FIELD_RESOURCE_LOGS = 1 + + // ResourceLogs + private const val FIELD_RL_RESOURCE = 1 + private const val FIELD_RL_SCOPE_LOGS = 2 + + // Resource + private const val FIELD_RESOURCE_ATTRIBUTES = 1 + + // ScopeLogs + private const val FIELD_SL_SCOPE = 1 + private const val FIELD_SL_LOG_RECORDS = 2 + + // InstrumentationScope + private const val FIELD_SCOPE_NAME = 1 + + // LogRecord + private const val FIELD_LR_TIME_UNIX_NANO = 1 + private const val FIELD_LR_SEVERITY_NUMBER = 2 + private const val FIELD_LR_SEVERITY_TEXT = 3 + private const val FIELD_LR_BODY = 5 + private const val FIELD_LR_ATTRIBUTES = 6 + private const val FIELD_LR_OBSERVED_TIME_UNIX_NANO = 11 + + // KeyValue + private const val FIELD_KV_KEY = 1 + private const val FIELD_KV_VALUE = 2 + + // AnyValue + private const val FIELD_ANY_STRING_VALUE = 1 fun encode( resourceAttributes: Map, records: List, ): ByteArray { - val request = OtlpExportLogsRequest( - resourceLogs = listOf( - OtlpResourceLogs( - resource = OtlpResource(resourceAttributes.toKeyValues()), - scopeLogs = listOf( - OtlpScopeLogs( - scope = OtlpScope(SCOPE_NAME), - logRecords = records.map { it.toOtlp() }, - ), - ), - ), - ), - ) - return json.encodeToString(OtlpExportLogsRequest.serializer(), request).encodeToByteArray() + val resourceLogs = + ProtoWriter().apply { + writeLengthDelimited(FIELD_RL_RESOURCE, encodeResource(resourceAttributes)) + writeLengthDelimited(FIELD_RL_SCOPE_LOGS, encodeScopeLogs(records)) + }.toByteArray() + + return ProtoWriter().apply { + writeLengthDelimited(FIELD_RESOURCE_LOGS, resourceLogs) + }.toByteArray() + } + + private fun encodeResource(attributes: Map): ByteArray { + val writer = ProtoWriter() + for ((key, value) in attributes.sortedEntries()) { + writer.writeLengthDelimited(FIELD_RESOURCE_ATTRIBUTES, encodeKeyValue(key, value)) + } + return writer.toByteArray() } - private fun EncodableRecord.toOtlp(): OtlpLogRecord { - val time = timeUnixNanos.toString() - return OtlpLogRecord( - timeUnixNano = time, - observedTimeUnixNano = time, - severityNumber = severity.severityNumber, - severityText = severity.severityText, - body = OtlpAnyValue(body), - attributes = attributes.toKeyValues(), - ) + private fun encodeScopeLogs(records: List): ByteArray { + val writer = ProtoWriter() + writer.writeLengthDelimited(FIELD_SL_SCOPE, encodeScope()) + for (record in records) { + writer.writeLengthDelimited(FIELD_SL_LOG_RECORDS, encodeLogRecord(record)) + } + return writer.toByteArray() } - private fun Map.toKeyValues(): List = - // Sort for deterministic output (stable payloads, easier testing/diffing). - entries.sortedBy { it.key }.map { OtlpKeyValue(it.key, OtlpAnyValue(it.value)) } + private fun encodeScope(): ByteArray = + ProtoWriter().apply { writeString(FIELD_SCOPE_NAME, SCOPE_NAME) }.toByteArray() + + private fun encodeLogRecord(record: EncodableRecord): ByteArray { + val writer = ProtoWriter() + writer.writeFixed64(FIELD_LR_TIME_UNIX_NANO, record.timeUnixNanos) + writer.writeVarintField(FIELD_LR_SEVERITY_NUMBER, record.severity.severityNumber.toLong()) + writer.writeString(FIELD_LR_SEVERITY_TEXT, record.severity.severityText) + writer.writeLengthDelimited(FIELD_LR_BODY, encodeAnyValue(record.body)) + for ((key, value) in record.attributes.sortedEntries().take(MAX_ATTRIBUTE_COUNT)) { + writer.writeLengthDelimited(FIELD_LR_ATTRIBUTES, encodeKeyValue(key, value.limitValueLength())) + } + writer.writeFixed64(FIELD_LR_OBSERVED_TIME_UNIX_NANO, record.timeUnixNanos) + return writer.toByteArray() + } + + private fun encodeKeyValue(key: String, value: String): ByteArray = + ProtoWriter().apply { + writeString(FIELD_KV_KEY, key) + writeLengthDelimited(FIELD_KV_VALUE, encodeAnyValue(value)) + }.toByteArray() + + private fun encodeAnyValue(value: String): ByteArray = + ProtoWriter().apply { + // proto3 omits empty scalar fields; mirror that so an empty value decodes to + // an unset oneof exactly as the OpenTelemetry marshaler produces. + if (value.isNotEmpty()) writeString(FIELD_ANY_STRING_VALUE, value) + }.toByteArray() + + // Sort for deterministic output (stable payloads, easier testing/diffing). + private fun Map.sortedEntries(): List> = entries.sortedBy { it.key } + + private fun String.limitValueLength(): String = + if (length > MAX_ATTRIBUTE_VALUE_LENGTH) substring(0, MAX_ATTRIBUTE_VALUE_LENGTH) else this } diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/ProtoWriter.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/ProtoWriter.kt new file mode 100644 index 000000000..ec9aa998c --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/ProtoWriter.kt @@ -0,0 +1,84 @@ +package com.onesignal.logger.otlp + +/** + * Minimal, dependency-free protobuf wire-format writer. + * + * Only the pieces the OTLP logs subset needs are implemented: varint, fixed64, and + * length-delimited (string/bytes/embedded-message) fields. This lets us produce the + * exact same wire format the OpenTelemetry Java SDK's OTLP/HTTP exporter emits + * (`Content-Type: application/x-protobuf`) from pure `commonMain` Kotlin, with no + * OpenTelemetry or serialization dependency. + * + * See https://protobuf.dev/programming-guides/encoding/ for the wire format. + */ +internal class ProtoWriter { + private var buffer = ByteArray(INITIAL_CAPACITY) + private var position = 0 + + private fun ensureCapacity(extra: Int) { + if (position + extra <= buffer.size) return + var newSize = buffer.size * 2 + while (newSize < position + extra) newSize *= 2 + buffer = buffer.copyOf(newSize) + } + + /** Writes a base-128 varint (used for tags, enums, and lengths). */ + private fun writeVarint(value: Long) { + ensureCapacity(VARINT_MAX_BYTES) + var remaining = value + while (true) { + val lower7 = (remaining and 0x7F).toInt() + remaining = remaining ushr 7 + if (remaining != 0L) { + buffer[position++] = (lower7 or 0x80).toByte() + } else { + buffer[position++] = lower7.toByte() + break + } + } + } + + private fun writeTag(fieldNumber: Int, wireType: Int) { + writeVarint(((fieldNumber shl 3) or wireType).toLong()) + } + + /** Writes a `fixed64` field (little-endian 8 bytes) — used for OTLP timestamps. */ + fun writeFixed64(fieldNumber: Int, value: Long) { + writeTag(fieldNumber, WIRE_TYPE_FIXED64) + ensureCapacity(Long.SIZE_BYTES) + var remaining = value + repeat(Long.SIZE_BYTES) { + buffer[position++] = (remaining and 0xFF).toByte() + remaining = remaining ushr 8 + } + } + + /** Writes a varint-encoded field — used for OTLP `severity_number` (an enum). */ + fun writeVarintField(fieldNumber: Int, value: Long) { + writeTag(fieldNumber, WIRE_TYPE_VARINT) + writeVarint(value) + } + + /** Writes a length-delimited field (bytes / embedded message). */ + fun writeLengthDelimited(fieldNumber: Int, value: ByteArray) { + writeTag(fieldNumber, WIRE_TYPE_LENGTH_DELIMITED) + writeVarint(value.size.toLong()) + ensureCapacity(value.size) + value.copyInto(buffer, position) + position += value.size + } + + /** Writes a length-delimited UTF-8 string field. */ + fun writeString(fieldNumber: Int, value: String) = writeLengthDelimited(fieldNumber, value.encodeToByteArray()) + + fun toByteArray(): ByteArray = buffer.copyOf(position) + + companion object { + private const val INITIAL_CAPACITY = 64 + private const val VARINT_MAX_BYTES = 10 + + const val WIRE_TYPE_VARINT = 0 + const val WIRE_TYPE_FIXED64 = 1 + const val WIRE_TYPE_LENGTH_DELIMITED = 2 + } +} diff --git a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogTelemetryTest.kt b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogTelemetryTest.kt index e103d45a2..b6aa69f3f 100644 --- a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogTelemetryTest.kt +++ b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogTelemetryTest.kt @@ -24,7 +24,7 @@ class LogTelemetryTest { ) @Test - fun emitThenForceFlushPostsOtlpJsonToCorrectEndpoint() = runTest { + fun emitThenForceFlushPostsOtlpProtobufToCorrectEndpoint() = runTest { val provider = FakePlatformProvider() val http = FakeHttpSender() val telemetry = remote(backgroundScope, provider, http) @@ -35,14 +35,15 @@ class LogTelemetryTest { assertEquals(1, http.sentRequests.size) val req = http.sentRequests[0] assertEquals("https://api.onesignal.com/sdk/log?app_id=app-123", req.url) - assertEquals("application/json", req.contentType) + assertEquals("application/x-protobuf", req.contentType) assertEquals("onesignal/android/5.9.5", req.headers["SDK-Version"]) - val body = http.lastBodyAsString() - assertTrue(body.contains("hello")) - assertTrue(body.contains("custom")) - // resource attribute attached - assertTrue(body.contains("ossdk.install_id")) + // Decode the OTLP/protobuf body and assert the record + resource attribute. + val record = parseProto(req.body).message(1).message(2).message(2) + assertEquals("hello", record.message(5).string(1)) + assertTrue(record.all(6).any { parseProto(it.bytes()).string(1) == "custom" }) + val resourceAttrKeys = parseProto(req.body).message(1).message(1).all(1).map { parseProto(it.bytes()).string(1) } + assertTrue(resourceAttrKeys.contains("ossdk.install_id")) } @Test @@ -76,9 +77,12 @@ class LogTelemetryTest { telemetry.emit(LogRecord(LogSeverity.FATAL, "crash!", mapOf("exception.type" to "X"))) assertEquals(1, store.entries.size) - val json = store.entries[0].bytes.decodeToString() - assertTrue(json.contains("crash!")) - assertTrue(json.contains("exception.type")) - assertTrue(json.contains("ossdk.install_id")) + // Stored crash record is OTLP/protobuf; decode and assert its contents. + val record = parseProto(store.entries[0].bytes).message(1).message(2).message(2) + assertEquals("crash!", record.message(5).string(1)) + assertTrue(record.all(6).any { parseProto(it.bytes()).string(1) == "exception.type" }) + val resourceAttrKeys = + parseProto(store.entries[0].bytes).message(1).message(1).all(1).map { parseProto(it.bytes()).string(1) } + assertTrue(resourceAttrKeys.contains("ossdk.install_id")) } } diff --git a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/OtlpLogEncoderTest.kt b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/OtlpLogEncoderTest.kt index 5d389b2a8..1a572b8f0 100644 --- a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/OtlpLogEncoderTest.kt +++ b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/OtlpLogEncoderTest.kt @@ -2,17 +2,13 @@ package com.onesignal.logger import com.onesignal.logger.otlp.EncodableRecord import com.onesignal.logger.otlp.OtlpLogEncoder -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.jsonArray -import kotlinx.serialization.json.jsonObject -import kotlinx.serialization.json.jsonPrimitive import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertTrue class OtlpLogEncoderTest { @Test - fun encodesValidOtlpJsonStructure() { + fun encodesValidOtlpProtobufStructure() { val bytes = OtlpLogEncoder.encode( resourceAttributes = mapOf("ossdk.install_id" to "abc", "os.name" to "Android"), @@ -27,24 +23,59 @@ class OtlpLogEncoderTest { ), ) - val root = Json.parseToJsonElement(bytes.decodeToString()).jsonObject - val resourceLogs = root["resourceLogs"]!!.jsonArray - assertEquals(1, resourceLogs.size) + // ExportLogsServiceRequest.resource_logs (1) -> ResourceLogs + val resourceLogs = parseProto(bytes).message(1) - val resource = resourceLogs[0].jsonObject["resource"]!!.jsonObject - val resourceAttrs = resource["attributes"]!!.jsonArray + // ResourceLogs.resource (1) -> Resource, attributes (1) repeated KeyValue, sorted by key. + val resourceAttrs = resourceLogs.message(1).all(1).map { parseProto(it.bytes()) } // os.name sorts before ossdk.install_id - assertEquals("os.name", resourceAttrs[0].jsonObject["key"]!!.jsonPrimitive.content) + assertEquals("os.name", resourceAttrs[0].string(1)) + assertEquals("Android", resourceAttrs[0].message(2).string(1)) + assertEquals("ossdk.install_id", resourceAttrs[1].string(1)) - val scopeLogs = resourceLogs[0].jsonObject["scopeLogs"]!!.jsonArray - val scope = scopeLogs[0].jsonObject["scope"]!!.jsonObject - assertEquals("OneSignalDeviceSDK", scope["name"]!!.jsonPrimitive.content) + // ResourceLogs.scope_logs (2) -> ScopeLogs + val scopeLogs = resourceLogs.message(2) + assertEquals("OneSignalDeviceSDK", scopeLogs.message(1).string(1)) - val record = scopeLogs[0].jsonObject["logRecords"]!!.jsonArray[0].jsonObject - assertEquals("1700000000000000000", record["timeUnixNano"]!!.jsonPrimitive.content) - assertEquals(17, record["severityNumber"]!!.jsonPrimitive.content.toInt()) - assertEquals("ERROR", record["severityText"]!!.jsonPrimitive.content) - assertEquals("boom", record["body"]!!.jsonObject["stringValue"]!!.jsonPrimitive.content) + // ScopeLogs.log_records (2) -> LogRecord + val record = scopeLogs.message(2) + // time_unix_nano (1, fixed64) and observed_time_unix_nano (11, fixed64) + assertEquals(1700000000000000000L, record.first(1).varint) + assertEquals(1700000000000000000L, record.first(11).varint) + // severity_number (2, varint) and severity_text (3, string) + assertEquals(17L, record.first(2).varint) + assertEquals("ERROR", record.string(3)) + // body (5) -> AnyValue.string_value (1) + assertEquals("boom", record.message(5).string(1)) + // attributes (6) repeated, sorted: log.level then log.message + val recordAttrs = record.all(6).map { parseProto(it.bytes()) } + assertEquals("log.level", recordAttrs[0].string(1)) + assertEquals("log.message", recordAttrs[1].string(1)) + assertEquals("boom", recordAttrs[1].message(2).string(1)) + } + + @Test + fun emptyStringValueOmittedToMatchProto3() { + // An empty attribute value must produce an AnyValue with NO string_value field + // set (matching proto3 / the OpenTelemetry marshaler), i.e. an empty message. + val bytes = + OtlpLogEncoder.encode( + resourceAttributes = emptyMap(), + records = + listOf( + EncodableRecord( + severity = LogSeverity.INFO, + body = "b", + attributes = mapOf("empty" to ""), + timeUnixNanos = 1L, + ), + ), + ) + val record = parseProto(bytes).message(1).message(2).message(2) + val attr = parseProto(record.all(6).single().bytes()) + assertEquals("empty", attr.string(1)) + // value (2) is present but its AnyValue message is empty (no field 1). + assertTrue(attr.message(2).all(1).isEmpty()) } @Test @@ -64,7 +95,52 @@ class OtlpLogEncoderTest { } @Test - fun contentTypeIsJson() { - assertTrue(OtlpLogEncoder.CONTENT_TYPE.contains("json")) + fun contentTypeIsProtobuf() { + assertEquals("application/x-protobuf", OtlpLogEncoder.CONTENT_TYPE) + } + + @Test + fun truncatesAttributeValuesToLogLimit() { + // Matches the old otel LogLimits.maxAttributeValueLength (32,000). A long + // exception.stacktrace value must be truncated on the wire. + val longValue = "x".repeat(40_000) + val bytes = + OtlpLogEncoder.encode( + resourceAttributes = emptyMap(), + records = + listOf( + EncodableRecord( + severity = LogSeverity.ERROR, + body = "boom", + attributes = mapOf("exception.stacktrace" to longValue), + timeUnixNanos = 1L, + ), + ), + ) + val record = parseProto(bytes).message(1).message(2).message(2) + val attr = parseProto(record.all(6).single().bytes()) + assertEquals("exception.stacktrace", attr.string(1)) + assertEquals(32_000, attr.message(2).string(1).length) + } + + @Test + fun capsAttributeCountToLogLimit() { + // Matches the old otel LogLimits.maxNumberOfAttributes (128). + val manyAttrs = (0 until 200).associate { "k${it.toString().padStart(3, '0')}" to "v$it" } + val bytes = + OtlpLogEncoder.encode( + resourceAttributes = emptyMap(), + records = + listOf( + EncodableRecord( + severity = LogSeverity.INFO, + body = "b", + attributes = manyAttrs, + timeUnixNanos = 1L, + ), + ), + ) + val record = parseProto(bytes).message(1).message(2).message(2) + assertEquals(128, record.all(6).size) } } diff --git a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/ProtoTestReader.kt b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/ProtoTestReader.kt new file mode 100644 index 000000000..7d1621ba9 --- /dev/null +++ b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/ProtoTestReader.kt @@ -0,0 +1,71 @@ +package com.onesignal.logger + +/** + * A tiny, independent protobuf reader used only by tests to verify [ + * com.onesignal.logger.otlp.OtlpLogEncoder] output. It is intentionally written + * separately from the production `ProtoWriter` so the tests validate the wire format + * (field numbers, wire types, values) rather than round-tripping through the same code. + */ +internal class ProtoMessage(private val fields: List) { + fun all(fieldNumber: Int): List = fields.filter { it.number == fieldNumber } + + fun first(fieldNumber: Int): ProtoField = + fields.firstOrNull { it.number == fieldNumber } + ?: error("field $fieldNumber not present (have ${fields.map { it.number }})") + + fun message(fieldNumber: Int): ProtoMessage = parseProto(first(fieldNumber).bytes()) + + fun string(fieldNumber: Int): String = first(fieldNumber).bytes().decodeToString() +} + +internal class ProtoField( + val number: Int, + val wireType: Int, + val varint: Long, + private val raw: ByteArray?, +) { + fun bytes(): ByteArray = raw ?: error("field $number is not length-delimited") +} + +internal fun parseProto(bytes: ByteArray): ProtoMessage { + val fields = ArrayList() + var index = 0 + + fun readVarint(): Long { + var shift = 0 + var result = 0L + while (true) { + val byte = bytes[index++].toInt() and 0xFF + result = result or ((byte.toLong() and 0x7F) shl shift) + if (byte < 0x80) break + shift += 7 + } + return result + } + + fun readFixed(byteCount: Int): Long { + var value = 0L + for (i in 0 until byteCount) { + value = value or ((bytes[index++].toLong() and 0xFF) shl (8 * i)) + } + return value + } + + while (index < bytes.size) { + val tag = readVarint().toInt() + val fieldNumber = tag ushr 3 + when (val wireType = tag and 0x7) { + 0 -> fields.add(ProtoField(fieldNumber, wireType, readVarint(), null)) + 1 -> fields.add(ProtoField(fieldNumber, wireType, readFixed(Long.SIZE_BYTES), null)) + 2 -> { + val length = readVarint().toInt() + val slice = bytes.copyOfRange(index, index + length) + index += length + fields.add(ProtoField(fieldNumber, wireType, 0, slice)) + } + 5 -> fields.add(ProtoField(fieldNumber, wireType, readFixed(Int.SIZE_BYTES), null)) + else -> error("unsupported wire type $wireType") + } + } + return ProtoMessage(fields) +} diff --git a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/TestFakes.kt b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/TestFakes.kt index c85f420ef..9b31374ef 100644 --- a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/TestFakes.kt +++ b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/TestFakes.kt @@ -72,12 +72,12 @@ internal class FakeFileStore : ILogFileStore { entries.add(Entry(id, bytes, ageMillis)) } - override fun listReadable(minAgeMillis: Long): List = + override suspend fun listReadable(minAgeMillis: Long): List = entries .filter { it.ageMillis >= minAgeMillis && it.id !in deletedIds } .map { StoredLogFile(it.id, it.bytes) } - override fun delete(id: String) { + override suspend fun delete(id: String) { deletedIds.add(id) entries.removeAll { it.id == id } } From a15d20f2a122d71f937e7c6a6d915952c4a3b9a2 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 21 Jul 2026 15:16:36 +0530 Subject: [PATCH 03/15] refactor(logger): [SDK-4835] consume shared KMP logger via OneSignal-KMP-SDK submodule Extract the multiplatform :logger module into the standalone OneSignal-KMP-SDK repo (single source of truth for Android + iOS parity) and consume it here as a git submodule pinned to a commit. Remap the :OneSignal:logger project directory to the submodule so the existing project path and dependency substitution are unchanged. Fetch submodules recursively in every CI checkout that compiles the SDK, and document the clone/init workflow in CONTRIBUTING and README. Co-authored-by: Cursor --- .github/actions/project-setup/action.yml | 1 + .github/workflows/codeql.yml | 2 + .github/workflows/e2e.yml | 2 + .github/workflows/publish-release.yml | 1 + .gitmodules | 3 + CONTRIBUTING.md | 14 ++ OneSignal-KMP-SDK | 1 + OneSignalSDK/onesignal/logger/build.gradle | 87 ----------- .../logger/internal/Platform.android.kt | 9 -- .../kotlin/com/onesignal/logger/CrashData.kt | 17 -- .../kotlin/com/onesignal/logger/ILogCrash.kt | 34 ---- .../com/onesignal/logger/ILogFileStore.kt | 57 ------- .../com/onesignal/logger/ILogHttpSender.kt | 47 ------ .../com/onesignal/logger/ILogTelemetry.kt | 36 ----- .../kotlin/com/onesignal/logger/ILogger.kt | 17 -- .../logger/ILoggerPlatformProvider.kt | 84 ---------- .../com/onesignal/logger/LogLoggingHelper.kt | 34 ---- .../kotlin/com/onesignal/logger/LogRecord.kt | 22 --- .../com/onesignal/logger/LogSeverity.kt | 38 ----- .../com/onesignal/logger/LoggerFactory.kt | 63 -------- .../onesignal/logger/attributes/LogFields.kt | 76 --------- .../logger/crash/LogCrashReporter.kt | 44 ------ .../logger/crash/LogCrashUploader.kt | 67 -------- .../logger/internal/LogBatchProcessor.kt | 70 --------- .../onesignal/logger/internal/LogEndpoint.kt | 14 -- .../logger/internal/LogTelemetryCrashImpl.kt | 43 ------ .../logger/internal/LogTelemetryRemoteImpl.kt | 106 ------------- .../com/onesignal/logger/internal/Platform.kt | 12 -- .../onesignal/logger/otlp/OtlpLogEncoder.kt | 143 ----------------- .../com/onesignal/logger/otlp/ProtoWriter.kt | 84 ---------- .../onesignal/logger/LogBatchProcessorTest.kt | 41 ----- .../com/onesignal/logger/LogCrashTest.kt | 94 ----------- .../com/onesignal/logger/LogFieldsTest.kt | 48 ------ .../com/onesignal/logger/LogTelemetryTest.kt | 88 ----------- .../onesignal/logger/OtlpLogEncoderTest.kt | 146 ------------------ .../com/onesignal/logger/ProtoTestReader.kt | 71 --------- .../kotlin/com/onesignal/logger/TestFakes.kt | 93 ----------- .../onesignal/logger/internal/Platform.ios.kt | 11 -- OneSignalSDK/settings.gradle | 6 + README.md | 2 + 40 files changed, 32 insertions(+), 1796 deletions(-) create mode 100644 .gitmodules create mode 160000 OneSignal-KMP-SDK delete mode 100644 OneSignalSDK/onesignal/logger/build.gradle delete mode 100644 OneSignalSDK/onesignal/logger/src/androidMain/kotlin/com/onesignal/logger/internal/Platform.android.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/CrashData.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogCrash.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogFileStore.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogHttpSender.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogTelemetry.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogger.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILoggerPlatformProvider.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogLoggingHelper.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogRecord.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogSeverity.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LoggerFactory.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/attributes/LogFields.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/crash/LogCrashReporter.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/crash/LogCrashUploader.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogBatchProcessor.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogEndpoint.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryCrashImpl.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryRemoteImpl.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/Platform.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpLogEncoder.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/ProtoWriter.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogBatchProcessorTest.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogCrashTest.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogFieldsTest.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogTelemetryTest.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/OtlpLogEncoderTest.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/ProtoTestReader.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/TestFakes.kt delete mode 100644 OneSignalSDK/onesignal/logger/src/iosMain/kotlin/com/onesignal/logger/internal/Platform.ios.kt diff --git a/.github/actions/project-setup/action.yml b/.github/actions/project-setup/action.yml index 18bceda33..5b78d1d00 100644 --- a/.github/actions/project-setup/action.yml +++ b/.github/actions/project-setup/action.yml @@ -5,6 +5,7 @@ runs: uses: actions/checkout@v4 with: fetch-depth: 0 + submodules: recursive - name: Setup Java uses: actions/setup-java@v4 diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 48a1ff99d..868220850 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -41,6 +41,8 @@ jobs: steps: - name: Checkout repository uses: actions/checkout@v6 + with: + submodules: recursive # Add any setup steps before running the `github/codeql-action/init` action. # This includes steps like installing compilers or runtimes (`actions/setup-node` diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index c05cada92..e7a3de63e 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -24,6 +24,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 + with: + submodules: recursive # Fail fast on unset var. Otherwise `demoOverride()` collapses "" to # null and the build silently uses the hardcoded default app ID. diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index c4dbeb4a8..f96a991cb 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -41,6 +41,7 @@ jobs: uses: actions/checkout@v5 with: ref: ${{ github.event.inputs.ref || github.sha }} + submodules: recursive - name: Detect current branch id: detect_branch diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..e46bacde6 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "OneSignal-KMP-SDK"] + path = OneSignal-KMP-SDK + url = git@github.com:OneSignal/OneSignal-KMP-SDK.git diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0c25e37c3..25d1edd36 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,6 +9,20 @@ If your proposed contribution is a small bug fix, please feel free to create you If your contribution would _break_ or _change_ the functionality of the SDK, please reach out to us on (contact) before you put in a lot of effort into a change we may not be able to use. We try our best to make sure that the SDK remains stable so that developers do not have to continually change their code, however some breaking changes _are_ desirable, so please get in touch to discuss your idea before you put in a lot of effort. +### Building the SDK from source + +The shared Kotlin Multiplatform `:logger` module lives in the standalone [OneSignal-KMP-SDK](https://github.com/OneSignal/OneSignal-KMP-SDK) repository and is consumed here as a git submodule pinned to a specific commit. Its sources are checked out under `OneSignal-KMP-SDK/`, so you must initialize the submodule before building or the Gradle build will fail (the remapped `:OneSignal:logger` project directory won't exist). + +```bash +# Fresh clone (recommended) +git clone --recurse-submodules git@github.com:OneSignal/OneSignal-Android-SDK.git + +# If you already cloned without --recurse-submodules +git submodule update --init --recursive +``` + +Whenever you pull changes that move the submodule pointer, re-run `git submodule update --init --recursive` to sync your local checkout to the pinned commit. + #### Before Submitting A Bug Report Before creating bug reports, please check this list of steps to follow. diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK new file mode 160000 index 000000000..f42169675 --- /dev/null +++ b/OneSignal-KMP-SDK @@ -0,0 +1 @@ +Subproject commit f4216967533c9e23097fa1bf1ecf29de02d9e9d2 diff --git a/OneSignalSDK/onesignal/logger/build.gradle b/OneSignalSDK/onesignal/logger/build.gradle deleted file mode 100644 index d8ed7e55a..000000000 --- a/OneSignalSDK/onesignal/logger/build.gradle +++ /dev/null @@ -1,87 +0,0 @@ -plugins { - id 'org.jetbrains.kotlin.multiplatform' - id 'com.android.library' - id 'com.diffplug.spotless' -} - -// Kotlin Multiplatform "logger" module. -// -// This is the platform-agnostic, OpenTelemetry-free replacement for the `otel` -// module. ALL logic lives in `commonMain` (pure Kotlin, no Android or JVM-only -// OpenTelemetry types) so the exact same pipeline can be shared with the iOS SDK. -// -// The module deliberately follows the same decoupled shape as `otel`: -// - platform values are injected via ILoggerPlatformProvider -// - logging is injected via ILogger -// - HTTP and file IO are injected via ILogHttpSender / ILogFileStore -// - everything is composed through LoggerFactory -// This keeps the module honest about its dependencies (i.e. "not so easy to use") -// while making the eventual core swap from `otel` -> `logger` near-mechanical. - -kotlin { - androidTarget { - compilations.all { - kotlinOptions { - jvmTarget = '1.8' - } - } - } - - // iOS targets — these make the module genuinely multiplatform. They are not - // compiled by the Android build (Android tasks only touch the android target), - // so they don't block the Android swap-and-test goal. - iosX64() - iosArm64() - iosSimulatorArm64() - - sourceSets { - commonMain { - dependencies { - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion" - } - } - commonTest { - dependencies { - implementation kotlin('test') - implementation "org.jetbrains.kotlinx:kotlinx-coroutines-test:$coroutinesVersion" - } - } - } -} - -android { - namespace 'com.onesignal.logger' - compileSdkVersion rootProject.buildVersions.compileSdkVersion - - defaultConfig { - // Matches the `otel` module. Runtime callers gate usage on SDK level the - // same way `otel` does, so consuming modules (min 21) override the merge. - minSdkVersion 26 - } - - // Must mirror the build types declared by consuming modules (e.g. :core) so - // Gradle variant matching can resolve this module for every variant. - buildTypes { - original { - minifyEnabled false - } - release { - minifyEnabled false - } - unity { - minifyEnabled false - } - } - - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 - } -} - -ext { - projectName = "OneSignal SDK Logger" - projectDescription = "OneSignal SDK - Multiplatform logging/telemetry module" -} - -apply from: '../spotless.gradle' diff --git a/OneSignalSDK/onesignal/logger/src/androidMain/kotlin/com/onesignal/logger/internal/Platform.android.kt b/OneSignalSDK/onesignal/logger/src/androidMain/kotlin/com/onesignal/logger/internal/Platform.android.kt deleted file mode 100644 index a66fcc98a..000000000 --- a/OneSignalSDK/onesignal/logger/src/androidMain/kotlin/com/onesignal/logger/internal/Platform.android.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.onesignal.logger.internal - -import java.util.UUID - -internal actual fun epochNanosNow(): Long = System.currentTimeMillis() * NANOS_PER_MILLI - -internal actual fun randomUuidString(): String = UUID.randomUUID().toString() - -private const val NANOS_PER_MILLI = 1_000_000L diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/CrashData.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/CrashData.kt deleted file mode 100644 index 7f089b451..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/CrashData.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.onesignal.logger - -/** - * Platform-neutral description of a crash. - * - * The old `otel` module took a JVM `Thread` + `Throwable` directly, which is not - * expressible in `commonMain`. Crash *capture* is inherently platform-specific - * (Android: `Thread.UncaughtExceptionHandler`; iOS: signal/NSException handlers), - * so the platform is responsible for translating its native crash representation - * into this neutral shape before handing it to [ILogCrashReporter]. - */ -data class CrashData( - val threadName: String, - val exceptionType: String, - val exceptionMessage: String, - val stacktrace: String, -) diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogCrash.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogCrash.kt deleted file mode 100644 index 0fdf750a0..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogCrash.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.onesignal.logger - -/** - * Platform-agnostic crash reporter. Persists a captured crash so it can be shipped - * on the next launch. - */ -interface ILogCrashReporter { - suspend fun saveCrash(crash: CrashData) -} - -/** - * Platform-agnostic crash handler. Registration of the native handler is - * platform-specific (Android: `Thread.UncaughtExceptionHandler`), so the - * implementation lives in the platform layer; this interface is the contract the - * lifecycle owner uses. - */ -interface ILogCrashHandler { - /** Installs the crash handler. Call as early as possible. */ - fun initialize() - - /** Restores the previous handler. Safe to call if never initialized. */ - fun unregister() -} - -/** - * Platform-agnostic ANR (Application Not Responding) detector. ANRs are detected - * by monitoring main-thread responsiveness, which is platform-specific, so the - * implementation lives in the platform layer. - */ -interface ILogAnrDetector { - fun start() - - fun stop() -} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogFileStore.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogFileStore.kt deleted file mode 100644 index b3a38e4ef..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogFileStore.kt +++ /dev/null @@ -1,57 +0,0 @@ -package com.onesignal.logger - -/** - * A stored crash record on disk. - * - * @property id opaque identifier (the platform decides what this is — e.g. a file - * name). Used to [ILogFileStore.read] and [ILogFileStore.delete] the entry. - * @property bytes the encoded payload that was written. - */ -data class StoredLogFile( - val id: String, - val bytes: ByteArray, -) { - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is StoredLogFile) return false - return id == other.id && bytes.contentEquals(other.bytes) - } - - override fun hashCode(): Int = 31 * id.hashCode() + bytes.contentHashCode() -} - -/** - * Platform-agnostic durable store for crash records. - * - * Replaces OpenTelemetry's `disk-buffering` contrib library. The on-disk format is - * entirely owned by this module (each record is one encoded OTLP/protobuf payload), so - * the same logic works on any platform that can provide simple file primitives. - * - * Implementations must be safe to call from a crash path (i.e. cheap, no heavy - * initialization). - */ -interface ILogFileStore { - /** - * Persists [bytes] under a newly generated entry. - * - * Intentionally NOT a suspend function: the only caller is the crash sink, which - * runs on the crashing thread inside the uncaught-exception handler and must - * complete the write synchronously before the process dies. Implementations must - * keep it cheap and never offload to another thread/queue on this path. - */ - fun save(bytes: ByteArray) - - /** - * Returns all readable entries whose age is at least [minAgeMillis]. The age - * gate mirrors `minFileAgeForReadMillis` from the old pipeline: it guarantees - * we never read a file that may still be mid-write from the crashing process. - * - * Suspends so implementations can perform the (blocking) directory scan and reads - * on a background dispatcher — keeping the shared upload pipeline off the caller's - * thread on every platform, and bridging to Swift `async` on iOS. - */ - suspend fun listReadable(minAgeMillis: Long): List - - /** Deletes the entry with the given [id]. Safe to call if already gone. */ - suspend fun delete(id: String) -} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogHttpSender.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogHttpSender.kt deleted file mode 100644 index 3d80014f5..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogHttpSender.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.onesignal.logger - -/** - * Outbound HTTP request for log export. Body is already-encoded bytes (OTLP - * protobuf) with the matching [contentType]. - */ -data class LogHttpRequest( - val url: String, - val headers: Map, - val contentType: String, - val body: ByteArray, -) { - // Generated equals/hashCode for data classes with ByteArray are reference-based, - // which is fine here (requests are not used as map keys), but override for sanity. - override fun equals(other: Any?): Boolean { - if (this === other) return true - if (other !is LogHttpRequest) return false - return url == other.url && - headers == other.headers && - contentType == other.contentType && - body.contentEquals(other.body) - } - - override fun hashCode(): Int { - var result = url.hashCode() - result = 31 * result + headers.hashCode() - result = 31 * result + contentType.hashCode() - result = 31 * result + body.contentHashCode() - return result - } -} - -/** Result of a [ILogHttpSender] call. */ -data class LogHttpResponse( - val success: Boolean, - val statusCode: Int, - val message: String? = null, -) - -/** - * Platform-agnostic HTTP transport for shipping logs. Implemented by the platform - * (Android reuses the SDK's existing HTTP stack), keeping this module free of any - * networking dependency. - */ -interface ILogHttpSender { - suspend fun send(request: LogHttpRequest): LogHttpResponse -} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogTelemetry.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogTelemetry.kt deleted file mode 100644 index 933ce8fe8..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogTelemetry.kt +++ /dev/null @@ -1,36 +0,0 @@ -package com.onesignal.logger - -/** - * Platform-agnostic telemetry sink. Analogue of `IOtelOpenTelemetry`, but the - * public surface only uses plain Kotlin types (no OpenTelemetry types leak out). - */ -interface ILogTelemetry { - /** - * Records a single log record. For the remote sink this enqueues the record - * into the batch processor; for the crash sink this writes it to disk. - */ - suspend fun emit(record: LogRecord) - - /** Forces all pending records to be exported/persisted immediately. */ - suspend fun forceFlush() - - /** - * Shuts down the sink, flushing pending data and releasing resources. After - * this call the instance must not be reused. - */ - fun shutdown() -} - -/** Telemetry sink that ships records over the network (batched). */ -interface ILogTelemetryRemote : ILogTelemetry { - /** - * POSTs a pre-encoded payload immediately, bypassing the batch queue, and - * returns whether the export succeeded. Used by the crash uploader to ship - * disk-buffered crash records (which are stored already-encoded) on the next - * launch, preserving their original capture-time resource attributes. - */ - suspend fun exportEncoded(payload: ByteArray): Boolean -} - -/** Telemetry sink that persists records to local storage (crash buffering). */ -interface ILogTelemetryCrash : ILogTelemetry diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogger.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogger.kt deleted file mode 100644 index 312f7bb66..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILogger.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.onesignal.logger - -/** - * Platform-agnostic logger interface for the logger module. - * - * Mirrors `IOtelLogger`. Implementations are provided by the platform (on Android - * this delegates to the core `Logging` object). - */ -interface ILogger { - fun error(message: String) - - fun warn(message: String) - - fun info(message: String) - - fun debug(message: String) -} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILoggerPlatformProvider.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILoggerPlatformProvider.kt deleted file mode 100644 index ef4ca14ed..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/ILoggerPlatformProvider.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.onesignal.logger - -/** - * Platform-agnostic provider for all platform-specific values the logger pipeline - * needs. This is the direct analogue of `IOtelPlatformProvider`. - * - * Everything the module cannot compute in pure common code — device/app metadata, - * IDs, config, storage location — is injected here. Implementations live in the - * platform layer (Android: core module; iOS: Swift-backed actual later). - */ -interface ILoggerPlatformProvider { - // ---- Top-level / resource attributes (static, calculated once) ---- - - /** - * Installation ID for this device. Suspending because it may need to generate - * and persist a new ID on first access. - */ - 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? - - /** - * Canonical keys of feature flags currently enabled for this device. Read - * fresh on every access so per-event attributes reflect the current state. - * Defaults to empty for source/binary compatibility. - */ - val enabledFeatureFlags: List - get() = emptyList() - - // ---- Per-event attributes (dynamic, calculated per event) ---- - - val appId: String? - val onesignalId: String? - val pushSubscriptionId: String? - - /** "foreground", "background", or "unknown". */ - val appState: String - - /** Process uptime in milliseconds. */ - val processUptime: Long - - 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. Defaults to false on first launch (before remote config cached). - */ - val isRemoteLoggingEnabled: Boolean - - /** - * Minimum log level to send remotely (e.g. "ERROR", "WARN"). Null when remote - * logging config is empty / not yet cached. - */ - val remoteLogLevel: String? - - /** Debug-only toggle for local exporter diagnostics. */ - val isExporterLoggingEnabled: Boolean - - /** App id used for the `app_id` query param / headers. */ - val appIdForHeaders: String - - /** - * Base URL for the OneSignal API (e.g. "https://api.onesignal.com"). - * The log endpoint path is appended to this. - */ - val apiBaseUrl: String -} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogLoggingHelper.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogLoggingHelper.kt deleted file mode 100644 index 1eaee83ee..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogLoggingHelper.kt +++ /dev/null @@ -1,34 +0,0 @@ -package com.onesignal.logger - -/** - * Helper for turning a log call into a [LogRecord] and emitting it. Mirrors - * `OtelLoggingHelper`, giving the platform `Logging` integration a single, stable - * entry point that hides the record-construction details. - */ -object LogLoggingHelper { - suspend fun log( - telemetry: ILogTelemetry, - level: String, - message: String, - exceptionType: String? = null, - exceptionMessage: String? = null, - exceptionStacktrace: String? = null, - ) { - val attributes = - buildMap { - put("log.message", message) - put("log.level", level) - if (exceptionType != null) put("exception.type", exceptionType) - if (exceptionMessage != null) put("exception.message", exceptionMessage) - if (exceptionStacktrace != null) put("exception.stacktrace", exceptionStacktrace) - } - - telemetry.emit( - LogRecord( - severity = LogSeverity.fromLevelName(level), - body = message, - attributes = attributes, - ), - ) - } -} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogRecord.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogRecord.kt deleted file mode 100644 index 41bc0455a..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogRecord.kt +++ /dev/null @@ -1,22 +0,0 @@ -package com.onesignal.logger - -/** - * A single, fully platform-agnostic log record. - * - * This replaces the OpenTelemetry `LogRecordBuilder` / `LogRecordData` types that - * the old `otel` module leaked across its public surface. A record carries only - * its own (record-specific) attributes; the telemetry implementation merges in - * the per-event and top-level/resource attributes at emit/encode time. - * - * @property severity log severity - * @property body human-readable message (becomes the OTLP log body) - * @property attributes record-specific attributes (e.g. exception.* fields) - * @property timestampNanos event time in nanoseconds since the Unix epoch; when - * `null` the telemetry stamps it at emit time. - */ -data class LogRecord( - val severity: LogSeverity, - val body: String, - val attributes: Map = emptyMap(), - val timestampNanos: Long? = null, -) diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogSeverity.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogSeverity.kt deleted file mode 100644 index 61361d2cc..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LogSeverity.kt +++ /dev/null @@ -1,38 +0,0 @@ -package com.onesignal.logger - -/** - * Platform-agnostic severity model. - * - * Numeric values follow the OpenTelemetry log severity number scheme so that - * payloads remain wire-compatible with the existing OTLP ingestion endpoint, - * even though this module no longer depends on the OpenTelemetry SDK. - * - * See: https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber - */ -enum class LogSeverity(val severityNumber: Int, val severityText: String) { - TRACE(1, "TRACE"), - DEBUG(5, "DEBUG"), - INFO(9, "INFO"), - WARN(13, "WARN"), - ERROR(17, "ERROR"), - FATAL(21, "FATAL"), - ; - - companion object { - /** - * Maps a OneSignal [com.onesignal.debug.LogLevel] name (or any case variant) - * to a severity. Unknown values fall back to [INFO] to match the previous - * OtelLoggingHelper behavior. - */ - fun fromLevelName(level: String): LogSeverity = - when (level.uppercase()) { - "VERBOSE" -> TRACE - "DEBUG" -> DEBUG - "INFO" -> INFO - "WARN" -> WARN - "ERROR" -> ERROR - "FATAL" -> FATAL - else -> INFO - } - } -} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LoggerFactory.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LoggerFactory.kt deleted file mode 100644 index 591ce08a8..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/LoggerFactory.kt +++ /dev/null @@ -1,63 +0,0 @@ -package com.onesignal.logger - -import com.onesignal.logger.attributes.LogFieldsPerEvent -import com.onesignal.logger.attributes.LogFieldsTopLevel -import com.onesignal.logger.crash.LogCrashReporter -import com.onesignal.logger.crash.LogCrashUploader -import com.onesignal.logger.internal.LogTelemetryCrashImpl -import com.onesignal.logger.internal.LogTelemetryRemoteImpl - -/** - * Composition root for the logger module. Mirrors `OtelFactory`. - * - * Every dependency is injected (platform provider, HTTP sender, file store, logger), - * keeping the module free of any platform, networking, or storage coupling. This is - * the "decoupled but not so easy to use" trade-off — callers must supply the platform - * pieces, but in return the whole pipeline is multiplatform and testable. - */ -object LoggerFactory { - /** - * Creates a remote telemetry instance for shipping SDK log events over the - * network (batched, OTLP/protobuf). - */ - fun createRemoteTelemetry( - platformProvider: ILoggerPlatformProvider, - httpSender: ILogHttpSender, - ): ILogTelemetryRemote = - LogTelemetryRemoteImpl( - platformProvider = platformProvider, - httpSender = httpSender, - topLevelFields = LogFieldsTopLevel(platformProvider), - perEventFields = LogFieldsPerEvent(platformProvider), - ) - - /** - * Creates a crash telemetry instance that persists records to local storage so - * they survive process death. - */ - fun createCrashLocalTelemetry( - platformProvider: ILoggerPlatformProvider, - fileStore: ILogFileStore, - ): ILogTelemetryCrash = - LogTelemetryCrashImpl( - fileStore = fileStore, - topLevelFields = LogFieldsTopLevel(platformProvider), - perEventFields = LogFieldsPerEvent(platformProvider), - ) - - /** Creates a crash reporter that writes a captured crash to [crashTelemetry]. */ - fun createCrashReporter( - crashTelemetry: ILogTelemetryCrash, - logger: ILogger, - ): ILogCrashReporter = LogCrashReporter(crashTelemetry, logger) - - /** - * Creates a crash uploader that ships disk-buffered crash reports on next launch. - */ - fun createCrashUploader( - platformProvider: ILoggerPlatformProvider, - remote: ILogTelemetryRemote, - fileStore: ILogFileStore, - logger: ILogger, - ): LogCrashUploader = LogCrashUploader(platformProvider, remote, fileStore, logger) -} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/attributes/LogFields.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/attributes/LogFields.kt deleted file mode 100644 index c778c77b7..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/attributes/LogFields.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.onesignal.logger.attributes - -import com.onesignal.logger.ILoggerPlatformProvider -import com.onesignal.logger.internal.randomUuidString - -internal fun MutableMap.putIfValueNotNull(key: K, value: V?): MutableMap { - if (value != null) { - this[key] = value - } - return this -} - -/** - * Top-level / resource attributes. Included on every export and, per OTLP, attached - * to the `resource` rather than each record. Only values that cannot change during - * runtime belong here (they are fetched once and cached by the telemetry). - * - * Mirrors `OtelFieldsTopLevel` key-for-key. - */ -internal class LogFieldsTopLevel( - private val platformProvider: ILoggerPlatformProvider, -) { - suspend fun getAttributes(): Map { - val attributes: MutableMap = - mutableMapOf( - "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) - - return attributes.toMap() - } -} - -/** - * Per-event attributes. Recomputed for every record so each one reflects the current - * state (IDs, app state, enabled feature flags, etc.). - * - * Mirrors `OtelFieldsPerEvent` key-for-key. - */ -internal class LogFieldsPerEvent( - private val platformProvider: ILoggerPlatformProvider, -) { - fun getAttributes(): Map { - val attributes: MutableMap = mutableMapOf() - - attributes["log.record.uid"] = randomUuidString() - - attributes - .putIfValueNotNull("ossdk.app_id", platformProvider.appId) - .putIfValueNotNull("ossdk.onesignal_id", platformProvider.onesignalId) - .putIfValueNotNull("ossdk.push_subscription_id", platformProvider.pushSubscriptionId) - - attributes["app.state"] = platformProvider.appState - attributes["process.uptime"] = platformProvider.processUptime.toString() - attributes["thread.name"] = platformProvider.currentThreadName - - val enabledFlags = platformProvider.enabledFeatureFlags - if (enabledFlags.isNotEmpty()) { - attributes["ossdk.feature_flags"] = enabledFlags.sorted().joinToString(",") - } - - return attributes.toMap() - } -} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/crash/LogCrashReporter.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/crash/LogCrashReporter.kt deleted file mode 100644 index 118fcc04b..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/crash/LogCrashReporter.kt +++ /dev/null @@ -1,44 +0,0 @@ -package com.onesignal.logger.crash - -import com.onesignal.logger.CrashData -import com.onesignal.logger.ILogCrashReporter -import com.onesignal.logger.ILogTelemetryCrash -import com.onesignal.logger.ILogger -import com.onesignal.logger.LogRecord -import com.onesignal.logger.LogSeverity - -/** - * Persists a captured crash by emitting it to the crash (disk) telemetry sink and - * forcing a flush so it survives the imminent process death. - * - * Mirrors `OtelCrashReporter`, but takes a platform-neutral [CrashData] instead of a - * JVM `Thread`/`Throwable`. - */ -internal class LogCrashReporter( - private val crashTelemetry: ILogTelemetryCrash, - private val logger: ILogger, -) : ILogCrashReporter { - override suspend fun saveCrash(crash: CrashData) { - logger.info("LogCrashReporter: saving crash report for ${crash.exceptionType}") - - val body = crash.exceptionMessage.ifBlank { crash.exceptionType } - val record = - LogRecord( - severity = LogSeverity.FATAL, - body = body, - attributes = - mapOf( - "exception.message" to crash.exceptionMessage, - "exception.stacktrace" to crash.stacktrace, - "exception.type" to crash.exceptionType, - // Matches the top-level thread.name today, but kept distinct in - // case future refactors report from a different thread. - "ossdk.exception.thread.name" to crash.threadName, - ), - ) - - crashTelemetry.emit(record) - crashTelemetry.forceFlush() - logger.info("LogCrashReporter: crash report saved and flushed") - } -} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/crash/LogCrashUploader.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/crash/LogCrashUploader.kt deleted file mode 100644 index cae83e45e..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/crash/LogCrashUploader.kt +++ /dev/null @@ -1,67 +0,0 @@ -package com.onesignal.logger.crash - -import com.onesignal.logger.ILogFileStore -import com.onesignal.logger.ILogTelemetryRemote -import com.onesignal.logger.ILogger -import com.onesignal.logger.ILoggerPlatformProvider -import kotlinx.coroutines.delay - -/** - * Reads locally-buffered crash reports and ships them to OneSignal on the next app - * start. Mirrors `OtelCrashUploader`, but reads our own simple disk format instead of - * OpenTelemetry's disk-buffering library. - * - * Usage: - * ```kotlin - * val uploader = LoggerFactory.createCrashUploader(provider, remote, fileStore, logger) - * scope.launch { uploader.start() } - * ``` - */ -class LogCrashUploader internal constructor( - private val platformProvider: ILoggerPlatformProvider, - private val remote: ILogTelemetryRemote, - private val fileStore: ILogFileStore, - private val logger: ILogger, -) { - /** - * Starts the uploader. No-op when remote logging is disabled (NONE / null level). - */ - suspend fun start() { - val remoteLogLevel = platformProvider.remoteLogLevel - if (remoteLogLevel == null || remoteLogLevel == "NONE") { - logger.info("LogCrashUploader: remote logging disabled (level: $remoteLogLevel)") - return - } - logger.info("LogCrashUploader: starting") - internalStart() - } - - /** - * Sends reports twice for the same reasons as the old uploader: - * 1. Send crash reports as soon as possible (app may crash again quickly). - * 2. A report from the previous crash may only become readable after - * [ILoggerPlatformProvider.minFileAgeForReadMillis] has elapsed (so we never - * read a file the crashing process may still have been writing). - */ - internal suspend fun internalStart() { - sendReports() - delay(platformProvider.minFileAgeForReadMillis) - sendReports() - } - - private suspend fun sendReports() { - val reports = fileStore.listReadable(platformProvider.minFileAgeForReadMillis) - for (report in reports) { - logger.debug("LogCrashUploader: sending crash report ${report.id}") - val success = remote.exportEncoded(report.bytes) - logger.debug("LogCrashUploader: done crash report ${report.id}, success: $success") - if (success) { - // Only delete on success so a failed upload is retried next launch. - fileStore.delete(report.id) - } else { - // Stop on first failure to avoid hammering a failing network. - break - } - } - } -} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogBatchProcessor.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogBatchProcessor.kt deleted file mode 100644 index 0b016a53f..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogBatchProcessor.kt +++ /dev/null @@ -1,70 +0,0 @@ -package com.onesignal.logger.internal - -import com.onesignal.logger.otlp.EncodableRecord -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.channels.Channel -import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock -import kotlinx.coroutines.withTimeoutOrNull - -/** - * Coroutine-based batch processor. Hand-rolled, dependency-light replacement for - * OpenTelemetry's `BatchLogRecordProcessor`. - * - * Records are buffered and exported when either the buffer reaches [maxBatchSize] - * or [scheduleDelayMillis] elapses. When the buffer exceeds [maxQueueSize], new - * records are dropped (back-pressure-free, never blocks the caller's pipeline). - */ -internal class LogBatchProcessor( - private val scope: CoroutineScope, - private val maxQueueSize: Int, - private val maxBatchSize: Int, - private val scheduleDelayMillis: Long, - private val onExport: suspend (List) -> Unit, -) { - private val mutex = Mutex() - private val buffer = ArrayList() - - // CONFLATED: a flush request that arrives while one is pending is coalesced. - private val flushSignal = Channel(Channel.CONFLATED) - - init { - scope.launch { - while (isActive) { - // Wake on either the schedule delay or an explicit size-triggered signal. - withTimeoutOrNull(scheduleDelayMillis) { flushSignal.receive() } - drainAndExport() - } - } - } - - suspend fun enqueue(record: EncodableRecord) { - val triggerFlush = - mutex.withLock { - if (buffer.size >= maxQueueSize) { - return // queue full — drop, matching BatchLogRecordProcessor semantics - } - buffer.add(record) - buffer.size >= maxBatchSize - } - if (triggerFlush) { - flushSignal.trySend(Unit) - } - } - - /** Exports everything currently buffered. */ - suspend fun flush() = drainAndExport() - - private suspend fun drainAndExport() { - val batch = - mutex.withLock { - if (buffer.isEmpty()) return - val copy = buffer.toList() - buffer.clear() - copy - } - onExport(batch) - } -} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogEndpoint.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogEndpoint.kt deleted file mode 100644 index 84952a9d3..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogEndpoint.kt +++ /dev/null @@ -1,14 +0,0 @@ -package com.onesignal.logger.internal - -/** - * Builds the log ingestion endpoint, mirroring the path the OpenTelemetry exporter - * used: `{apiBaseUrl}/sdk/log?app_id={appId}`. - */ -internal object LogEndpoint { - private const val LOG_PATH = "sdk/log" - - fun build(apiBaseUrl: String, appId: String): String { - val base = apiBaseUrl.trimEnd('/') - return "$base/$LOG_PATH?app_id=$appId" - } -} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryCrashImpl.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryCrashImpl.kt deleted file mode 100644 index 7e7afea8a..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryCrashImpl.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.onesignal.logger.internal - -import com.onesignal.logger.ILogFileStore -import com.onesignal.logger.ILogTelemetryCrash -import com.onesignal.logger.LogRecord -import com.onesignal.logger.attributes.LogFieldsPerEvent -import com.onesignal.logger.attributes.LogFieldsTopLevel -import com.onesignal.logger.otlp.EncodableRecord -import com.onesignal.logger.otlp.OtlpLogEncoder - -/** - * Crash telemetry sink: encodes each record to OTLP/protobuf and writes it to the - * injected [ILogFileStore] immediately. This is what makes crash reports survive a - * process death — they are durably persisted before the process exits and shipped on - * the next launch by the crash uploader. - * - * Records are stored fully self-contained (resource attributes baked in at capture - * time) so the uploader can POST the stored bytes verbatim. - */ -internal class LogTelemetryCrashImpl( - private val fileStore: ILogFileStore, - private val topLevelFields: LogFieldsTopLevel, - private val perEventFields: LogFieldsPerEvent, -) : ILogTelemetryCrash { - override suspend fun emit(record: LogRecord) { - val resourceAttributes = topLevelFields.getAttributes() - val merged = perEventFields.getAttributes() + record.attributes - val encodable = - EncodableRecord( - severity = record.severity, - body = record.body, - attributes = merged, - timeUnixNanos = record.timestampNanos ?: epochNanosNow(), - ) - val bytes = OtlpLogEncoder.encode(resourceAttributes, listOf(encodable)) - fileStore.save(bytes) - } - - // Writes are synchronous in emit(); nothing is buffered, so flush is a no-op. - override suspend fun forceFlush() = Unit - - override fun shutdown() = Unit -} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryRemoteImpl.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryRemoteImpl.kt deleted file mode 100644 index 02a9563b3..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/LogTelemetryRemoteImpl.kt +++ /dev/null @@ -1,106 +0,0 @@ -package com.onesignal.logger.internal - -import com.onesignal.logger.ILogHttpSender -import com.onesignal.logger.ILogTelemetryRemote -import com.onesignal.logger.ILoggerPlatformProvider -import com.onesignal.logger.LogHttpRequest -import com.onesignal.logger.LogRecord -import com.onesignal.logger.attributes.LogFieldsPerEvent -import com.onesignal.logger.attributes.LogFieldsTopLevel -import com.onesignal.logger.otlp.EncodableRecord -import com.onesignal.logger.otlp.OtlpLogEncoder -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob -import kotlinx.coroutines.cancel -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -/** - * Remote telemetry sink: batches records and ships them as OTLP/protobuf over the - * injected [ILogHttpSender]. Resource (top-level) attributes are computed once and - * cached, mirroring the old SDK's resource caching. - */ -internal class LogTelemetryRemoteImpl( - private val platformProvider: ILoggerPlatformProvider, - private val httpSender: ILogHttpSender, - private val topLevelFields: LogFieldsTopLevel, - private val perEventFields: LogFieldsPerEvent, - private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Default), -) : ILogTelemetryRemote { - companion object { - private const val MAX_QUEUE_SIZE = 100 - private const val MAX_BATCH_SIZE = 100 - private const val SCHEDULE_DELAY_MILLIS = 1_000L - } - - private val endpoint: String by lazy { - LogEndpoint.build(platformProvider.apiBaseUrl, platformProvider.appIdForHeaders) - } - - private val headers: Map by lazy { - mapOf( - "SDK-Version" to "onesignal/${platformProvider.sdkBase}/${platformProvider.sdkBaseVersion}", - ) - } - - private val resourceMutex = Mutex() - private var cachedResourceAttributes: Map? = null - - private val batchProcessor = - LogBatchProcessor( - scope = scope, - maxQueueSize = MAX_QUEUE_SIZE, - maxBatchSize = MAX_BATCH_SIZE, - scheduleDelayMillis = SCHEDULE_DELAY_MILLIS, - onExport = ::exportBatch, - ) - - private suspend fun getResourceAttributes(): Map { - cachedResourceAttributes?.let { return it } - return resourceMutex.withLock { - cachedResourceAttributes ?: topLevelFields.getAttributes().also { cachedResourceAttributes = it } - } - } - - override suspend fun emit(record: LogRecord) { - val merged = perEventFields.getAttributes() + record.attributes - batchProcessor.enqueue( - EncodableRecord( - severity = record.severity, - body = record.body, - attributes = merged, - timeUnixNanos = record.timestampNanos ?: epochNanosNow(), - ), - ) - } - - private suspend fun exportBatch(records: List) { - val payload = OtlpLogEncoder.encode(getResourceAttributes(), records) - post(payload) - } - - override suspend fun exportEncoded(payload: ByteArray): Boolean = post(payload) - - private suspend fun post(payload: ByteArray): Boolean { - val response = - httpSender.send( - LogHttpRequest( - url = endpoint, - headers = headers, - contentType = OtlpLogEncoder.CONTENT_TYPE, - body = payload, - ), - ) - return response.success - } - - override suspend fun forceFlush() = batchProcessor.flush() - - override fun shutdown() { - // Best-effort: cancels the batch loop. Any sub-second buffered remote logs may - // be dropped — acceptable for continuous logging (crash records do not use this - // path). Callers wanting a guaranteed flush should call forceFlush() first. - scope.cancel() - } -} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/Platform.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/Platform.kt deleted file mode 100644 index 9bf5308a8..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/internal/Platform.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.onesignal.logger.internal - -/** - * Current wall-clock time in nanoseconds since the Unix epoch. - * - * Resolution is platform-dependent (typically millisecond precision scaled to - * nanos); only used for OTLP `timeUnixNano` stamping. - */ -internal expect fun epochNanosNow(): Long - -/** A random UUID string (lowercase, hyphenated). Used for log record idempotency. */ -internal expect fun randomUuidString(): String diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpLogEncoder.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpLogEncoder.kt deleted file mode 100644 index 56ef6681c..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/OtlpLogEncoder.kt +++ /dev/null @@ -1,143 +0,0 @@ -package com.onesignal.logger.otlp - -import com.onesignal.logger.LogSeverity - -/** - * A record ready to be encoded: severity/body/timestamp plus the FULLY-merged - * attribute set (per-event + record-specific). Resource/top-level attributes are - * supplied separately to the encoder. - */ -internal data class EncodableRecord( - val severity: LogSeverity, - val body: String, - val attributes: Map, - val timeUnixNanos: Long, -) - -/** - * Encodes log records into **OTLP/protobuf** bytes (`Content-Type: application/x-protobuf`). - * - * This deliberately matches what the OpenTelemetry Java SDK's `OtlpHttpLogRecordExporter` - * put on the wire (binary protobuf is its default; JSON is opt-in via `exportAsJson()`, - * which the old `otel` module never called). Producing the same wire format here means - * the OneSignal ingestion endpoint receives byte-for-byte the same kind of payload it - * always has — the transport swapped from the OpenTelemetry SDK to this module, but the - * bytes did not. - * - * Only the tiny subset of `opentelemetry-proto`'s `ExportLogsServiceRequest` needed for - * string-valued log records is implemented, hand-rolled via [ProtoWriter] so it runs on - * every Kotlin target with no dependency. Field numbers below come from - * `opentelemetry/proto/logs/v1/logs.proto` and `.../common/v1/common.proto`. - */ -internal object OtlpLogEncoder { - const val CONTENT_TYPE = "application/x-protobuf" - - private const val SCOPE_NAME = "OneSignalDeviceSDK" - - // Mirrors the old otel module's LogLimits (OtelConfigShared.LogLimitsConfig): the SDK - // capped each log record at 128 attributes and truncated attribute values to 32,000 - // chars (the exception.stacktrace value can be very long). Applied to log-record - // attributes only — resource attributes and the body are left untouched, exactly as - // LogLimits scoped it. - private const val MAX_ATTRIBUTE_COUNT = 128 - private const val MAX_ATTRIBUTE_VALUE_LENGTH = 32_000 - - // ExportLogsServiceRequest - private const val FIELD_RESOURCE_LOGS = 1 - - // ResourceLogs - private const val FIELD_RL_RESOURCE = 1 - private const val FIELD_RL_SCOPE_LOGS = 2 - - // Resource - private const val FIELD_RESOURCE_ATTRIBUTES = 1 - - // ScopeLogs - private const val FIELD_SL_SCOPE = 1 - private const val FIELD_SL_LOG_RECORDS = 2 - - // InstrumentationScope - private const val FIELD_SCOPE_NAME = 1 - - // LogRecord - private const val FIELD_LR_TIME_UNIX_NANO = 1 - private const val FIELD_LR_SEVERITY_NUMBER = 2 - private const val FIELD_LR_SEVERITY_TEXT = 3 - private const val FIELD_LR_BODY = 5 - private const val FIELD_LR_ATTRIBUTES = 6 - private const val FIELD_LR_OBSERVED_TIME_UNIX_NANO = 11 - - // KeyValue - private const val FIELD_KV_KEY = 1 - private const val FIELD_KV_VALUE = 2 - - // AnyValue - private const val FIELD_ANY_STRING_VALUE = 1 - - fun encode( - resourceAttributes: Map, - records: List, - ): ByteArray { - val resourceLogs = - ProtoWriter().apply { - writeLengthDelimited(FIELD_RL_RESOURCE, encodeResource(resourceAttributes)) - writeLengthDelimited(FIELD_RL_SCOPE_LOGS, encodeScopeLogs(records)) - }.toByteArray() - - return ProtoWriter().apply { - writeLengthDelimited(FIELD_RESOURCE_LOGS, resourceLogs) - }.toByteArray() - } - - private fun encodeResource(attributes: Map): ByteArray { - val writer = ProtoWriter() - for ((key, value) in attributes.sortedEntries()) { - writer.writeLengthDelimited(FIELD_RESOURCE_ATTRIBUTES, encodeKeyValue(key, value)) - } - return writer.toByteArray() - } - - private fun encodeScopeLogs(records: List): ByteArray { - val writer = ProtoWriter() - writer.writeLengthDelimited(FIELD_SL_SCOPE, encodeScope()) - for (record in records) { - writer.writeLengthDelimited(FIELD_SL_LOG_RECORDS, encodeLogRecord(record)) - } - return writer.toByteArray() - } - - private fun encodeScope(): ByteArray = - ProtoWriter().apply { writeString(FIELD_SCOPE_NAME, SCOPE_NAME) }.toByteArray() - - private fun encodeLogRecord(record: EncodableRecord): ByteArray { - val writer = ProtoWriter() - writer.writeFixed64(FIELD_LR_TIME_UNIX_NANO, record.timeUnixNanos) - writer.writeVarintField(FIELD_LR_SEVERITY_NUMBER, record.severity.severityNumber.toLong()) - writer.writeString(FIELD_LR_SEVERITY_TEXT, record.severity.severityText) - writer.writeLengthDelimited(FIELD_LR_BODY, encodeAnyValue(record.body)) - for ((key, value) in record.attributes.sortedEntries().take(MAX_ATTRIBUTE_COUNT)) { - writer.writeLengthDelimited(FIELD_LR_ATTRIBUTES, encodeKeyValue(key, value.limitValueLength())) - } - writer.writeFixed64(FIELD_LR_OBSERVED_TIME_UNIX_NANO, record.timeUnixNanos) - return writer.toByteArray() - } - - private fun encodeKeyValue(key: String, value: String): ByteArray = - ProtoWriter().apply { - writeString(FIELD_KV_KEY, key) - writeLengthDelimited(FIELD_KV_VALUE, encodeAnyValue(value)) - }.toByteArray() - - private fun encodeAnyValue(value: String): ByteArray = - ProtoWriter().apply { - // proto3 omits empty scalar fields; mirror that so an empty value decodes to - // an unset oneof exactly as the OpenTelemetry marshaler produces. - if (value.isNotEmpty()) writeString(FIELD_ANY_STRING_VALUE, value) - }.toByteArray() - - // Sort for deterministic output (stable payloads, easier testing/diffing). - private fun Map.sortedEntries(): List> = entries.sortedBy { it.key } - - private fun String.limitValueLength(): String = - if (length > MAX_ATTRIBUTE_VALUE_LENGTH) substring(0, MAX_ATTRIBUTE_VALUE_LENGTH) else this -} diff --git a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/ProtoWriter.kt b/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/ProtoWriter.kt deleted file mode 100644 index ec9aa998c..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonMain/kotlin/com/onesignal/logger/otlp/ProtoWriter.kt +++ /dev/null @@ -1,84 +0,0 @@ -package com.onesignal.logger.otlp - -/** - * Minimal, dependency-free protobuf wire-format writer. - * - * Only the pieces the OTLP logs subset needs are implemented: varint, fixed64, and - * length-delimited (string/bytes/embedded-message) fields. This lets us produce the - * exact same wire format the OpenTelemetry Java SDK's OTLP/HTTP exporter emits - * (`Content-Type: application/x-protobuf`) from pure `commonMain` Kotlin, with no - * OpenTelemetry or serialization dependency. - * - * See https://protobuf.dev/programming-guides/encoding/ for the wire format. - */ -internal class ProtoWriter { - private var buffer = ByteArray(INITIAL_CAPACITY) - private var position = 0 - - private fun ensureCapacity(extra: Int) { - if (position + extra <= buffer.size) return - var newSize = buffer.size * 2 - while (newSize < position + extra) newSize *= 2 - buffer = buffer.copyOf(newSize) - } - - /** Writes a base-128 varint (used for tags, enums, and lengths). */ - private fun writeVarint(value: Long) { - ensureCapacity(VARINT_MAX_BYTES) - var remaining = value - while (true) { - val lower7 = (remaining and 0x7F).toInt() - remaining = remaining ushr 7 - if (remaining != 0L) { - buffer[position++] = (lower7 or 0x80).toByte() - } else { - buffer[position++] = lower7.toByte() - break - } - } - } - - private fun writeTag(fieldNumber: Int, wireType: Int) { - writeVarint(((fieldNumber shl 3) or wireType).toLong()) - } - - /** Writes a `fixed64` field (little-endian 8 bytes) — used for OTLP timestamps. */ - fun writeFixed64(fieldNumber: Int, value: Long) { - writeTag(fieldNumber, WIRE_TYPE_FIXED64) - ensureCapacity(Long.SIZE_BYTES) - var remaining = value - repeat(Long.SIZE_BYTES) { - buffer[position++] = (remaining and 0xFF).toByte() - remaining = remaining ushr 8 - } - } - - /** Writes a varint-encoded field — used for OTLP `severity_number` (an enum). */ - fun writeVarintField(fieldNumber: Int, value: Long) { - writeTag(fieldNumber, WIRE_TYPE_VARINT) - writeVarint(value) - } - - /** Writes a length-delimited field (bytes / embedded message). */ - fun writeLengthDelimited(fieldNumber: Int, value: ByteArray) { - writeTag(fieldNumber, WIRE_TYPE_LENGTH_DELIMITED) - writeVarint(value.size.toLong()) - ensureCapacity(value.size) - value.copyInto(buffer, position) - position += value.size - } - - /** Writes a length-delimited UTF-8 string field. */ - fun writeString(fieldNumber: Int, value: String) = writeLengthDelimited(fieldNumber, value.encodeToByteArray()) - - fun toByteArray(): ByteArray = buffer.copyOf(position) - - companion object { - private const val INITIAL_CAPACITY = 64 - private const val VARINT_MAX_BYTES = 10 - - const val WIRE_TYPE_VARINT = 0 - const val WIRE_TYPE_FIXED64 = 1 - const val WIRE_TYPE_LENGTH_DELIMITED = 2 - } -} diff --git a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogBatchProcessorTest.kt b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogBatchProcessorTest.kt deleted file mode 100644 index 08abb7342..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogBatchProcessorTest.kt +++ /dev/null @@ -1,41 +0,0 @@ -package com.onesignal.logger - -import com.onesignal.logger.internal.LogBatchProcessor -import com.onesignal.logger.otlp.EncodableRecord -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals - -class LogBatchProcessorTest { - private fun rec(body: String) = - EncodableRecord(LogSeverity.INFO, body, emptyMap(), 1L) - - @Test - fun flushExportsBufferedRecordsAsSingleBatch() = runTest { - val exported = mutableListOf>() - // Very large schedule delay so only the explicit flush triggers an export. - val proc = LogBatchProcessor(backgroundScope, 100, 100, 100_000L) { exported.add(it) } - - proc.enqueue(rec("a")) - proc.enqueue(rec("b")) - proc.flush() - - assertEquals(1, exported.size) - assertEquals(2, exported[0].size) - } - - @Test - fun dropsRecordsWhenQueueIsFull() = runTest { - val exported = mutableListOf>() - val proc = LogBatchProcessor(backgroundScope, maxQueueSize = 2, maxBatchSize = 100, scheduleDelayMillis = 100_000L) { - exported.add(it) - } - - proc.enqueue(rec("a")) - proc.enqueue(rec("b")) - proc.enqueue(rec("c")) // dropped: queue already at capacity - proc.flush() - - assertEquals(2, exported[0].size) - } -} diff --git a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogCrashTest.kt b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogCrashTest.kt deleted file mode 100644 index ab86a831b..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogCrashTest.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.onesignal.logger - -import com.onesignal.logger.attributes.LogFieldsPerEvent -import com.onesignal.logger.attributes.LogFieldsTopLevel -import com.onesignal.logger.internal.LogTelemetryRemoteImpl -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class LogCrashTest { - private fun remote( - scope: kotlinx.coroutines.CoroutineScope, - provider: FakePlatformProvider, - http: FakeHttpSender, - ) = LogTelemetryRemoteImpl( - platformProvider = provider, - httpSender = http, - topLevelFields = LogFieldsTopLevel(provider), - perEventFields = LogFieldsPerEvent(provider), - scope = scope, - ) - - @Test - fun reporterSavesFatalRecordWithExceptionAttributes() = runTest { - val provider = FakePlatformProvider() - val store = FakeFileStore() - val crashTelemetry = LoggerFactory.createCrashLocalTelemetry(provider, store) - val reporter = LoggerFactory.createCrashReporter(crashTelemetry, RecordingLogger()) - - reporter.saveCrash( - CrashData( - threadName = "main", - exceptionType = "java.lang.NullPointerException", - exceptionMessage = "npe message", - stacktrace = "stack...", - ), - ) - - assertEquals(1, store.entries.size) - val json = store.entries[0].bytes.decodeToString() - assertTrue(json.contains("npe message")) - assertTrue(json.contains("java.lang.NullPointerException")) - assertTrue(json.contains("ossdk.exception.thread.name")) - } - - @Test - fun uploaderSendsReadableReportsAndDeletesOnSuccess() = runTest { - val provider = FakePlatformProvider(minFileAgeForReadMillis = 0) - val store = FakeFileStore() - store.seed("f1", "payload1".encodeToByteArray(), ageMillis = Long.MAX_VALUE) - store.seed("f2", "payload2".encodeToByteArray(), ageMillis = Long.MAX_VALUE) - val http = FakeHttpSender() - val uploader = - LoggerFactory.createCrashUploader(provider, remote(backgroundScope, provider, http), store, RecordingLogger()) - - uploader.start() - - assertEquals(2, http.sentRequests.size) - assertTrue("f1" in store.deletedIds) - assertTrue("f2" in store.deletedIds) - } - - @Test - fun uploaderStopsOnFailureAndKeepsReports() = runTest { - val provider = FakePlatformProvider() - val store = FakeFileStore() - store.seed("f1", "p1".encodeToByteArray(), ageMillis = Long.MAX_VALUE) - store.seed("f2", "p2".encodeToByteArray(), ageMillis = Long.MAX_VALUE) - val http = FakeHttpSender(defaultResponse = LogHttpResponse(success = false, statusCode = 500)) - val uploader = - LoggerFactory.createCrashUploader(provider, remote(backgroundScope, provider, http), store, RecordingLogger()) - - uploader.start() - - // Two passes (start sends, then again after the read-age delay), each stops at f1. - assertEquals(2, http.sentRequests.size) - assertTrue(store.deletedIds.isEmpty()) - } - - @Test - fun uploaderNoOpWhenRemoteLoggingDisabled() = runTest { - val provider = FakePlatformProvider(remoteLogLevel = "NONE") - val store = FakeFileStore() - store.seed("f1", "p".encodeToByteArray(), ageMillis = Long.MAX_VALUE) - val http = FakeHttpSender() - val uploader = - LoggerFactory.createCrashUploader(provider, remote(backgroundScope, provider, http), store, RecordingLogger()) - - uploader.start() - - assertEquals(0, http.sentRequests.size) - } -} diff --git a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogFieldsTest.kt b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogFieldsTest.kt deleted file mode 100644 index 0be767d95..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogFieldsTest.kt +++ /dev/null @@ -1,48 +0,0 @@ -package com.onesignal.logger - -import com.onesignal.logger.attributes.LogFieldsPerEvent -import com.onesignal.logger.attributes.LogFieldsTopLevel -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class LogFieldsTest { - @Test - fun topLevelIncludesExpectedKeysAndOmitsNullWrapper() = runTest { - val provider = FakePlatformProvider() - val attrs = LogFieldsTopLevel(provider).getAttributes() - - assertEquals("install-abc", attrs["ossdk.install_id"]) - assertEquals("android", attrs["ossdk.sdk_base"]) - assertEquals("Android", attrs["os.name"]) - assertFalse(attrs.containsKey("ossdk.sdk_wrapper")) - } - - @Test - fun perEventIncludesDynamicValuesAndUniqueRecordId() { - val provider = FakePlatformProvider() - val fields = LogFieldsPerEvent(provider) - - val a = fields.getAttributes() - val b = fields.getAttributes() - - assertEquals("app-123", a["ossdk.app_id"]) - assertEquals("foreground", a["app.state"]) - assertEquals("1234", a["process.uptime"]) - assertEquals("test-thread", a["thread.name"]) - // record uid must be unique per event - assertTrue(a["log.record.uid"] != b["log.record.uid"]) - } - - @Test - fun featureFlagsAreSortedCsvAndOmittedWhenEmpty() { - val provider = FakePlatformProvider(enabledFeatureFlags = listOf("zeta", "alpha")) - val attrs = LogFieldsPerEvent(provider).getAttributes() - assertEquals("alpha,zeta", attrs["ossdk.feature_flags"]) - - val empty = LogFieldsPerEvent(FakePlatformProvider()).getAttributes() - assertFalse(empty.containsKey("ossdk.feature_flags")) - } -} diff --git a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogTelemetryTest.kt b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogTelemetryTest.kt deleted file mode 100644 index b6aa69f3f..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/LogTelemetryTest.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.onesignal.logger - -import com.onesignal.logger.attributes.LogFieldsPerEvent -import com.onesignal.logger.attributes.LogFieldsTopLevel -import com.onesignal.logger.internal.LogTelemetryCrashImpl -import com.onesignal.logger.internal.LogTelemetryRemoteImpl -import kotlinx.coroutines.test.runTest -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertTrue - -class LogTelemetryTest { - private fun remote( - scope: kotlinx.coroutines.CoroutineScope, - provider: FakePlatformProvider, - http: FakeHttpSender, - ) = LogTelemetryRemoteImpl( - platformProvider = provider, - httpSender = http, - topLevelFields = LogFieldsTopLevel(provider), - perEventFields = LogFieldsPerEvent(provider), - scope = scope, - ) - - @Test - fun emitThenForceFlushPostsOtlpProtobufToCorrectEndpoint() = runTest { - val provider = FakePlatformProvider() - val http = FakeHttpSender() - val telemetry = remote(backgroundScope, provider, http) - - telemetry.emit(LogRecord(LogSeverity.ERROR, "hello", mapOf("custom" to "v"))) - telemetry.forceFlush() - - assertEquals(1, http.sentRequests.size) - val req = http.sentRequests[0] - assertEquals("https://api.onesignal.com/sdk/log?app_id=app-123", req.url) - assertEquals("application/x-protobuf", req.contentType) - assertEquals("onesignal/android/5.9.5", req.headers["SDK-Version"]) - - // Decode the OTLP/protobuf body and assert the record + resource attribute. - val record = parseProto(req.body).message(1).message(2).message(2) - assertEquals("hello", record.message(5).string(1)) - assertTrue(record.all(6).any { parseProto(it.bytes()).string(1) == "custom" }) - val resourceAttrKeys = parseProto(req.body).message(1).message(1).all(1).map { parseProto(it.bytes()).string(1) } - assertTrue(resourceAttrKeys.contains("ossdk.install_id")) - } - - @Test - fun exportEncodedPostsRawBytes() = runTest { - val provider = FakePlatformProvider() - val http = FakeHttpSender() - val telemetry = remote(backgroundScope, provider, http) - - val ok = telemetry.exportEncoded("raw-bytes".encodeToByteArray()) - - assertTrue(ok) - assertEquals("raw-bytes", http.lastBodyAsString()) - } - - @Test - fun exportEncodedReturnsFalseOnHttpFailure() = runTest { - val provider = FakePlatformProvider() - val http = FakeHttpSender(defaultResponse = LogHttpResponse(success = false, statusCode = 500)) - val telemetry = remote(backgroundScope, provider, http) - - assertFalse(telemetry.exportEncoded("x".encodeToByteArray())) - } - - @Test - fun crashTelemetryWritesEncodedRecordToFileStore() = runTest { - val provider = FakePlatformProvider() - val store = FakeFileStore() - val telemetry = - LogTelemetryCrashImpl(store, LogFieldsTopLevel(provider), LogFieldsPerEvent(provider)) - - telemetry.emit(LogRecord(LogSeverity.FATAL, "crash!", mapOf("exception.type" to "X"))) - - assertEquals(1, store.entries.size) - // Stored crash record is OTLP/protobuf; decode and assert its contents. - val record = parseProto(store.entries[0].bytes).message(1).message(2).message(2) - assertEquals("crash!", record.message(5).string(1)) - assertTrue(record.all(6).any { parseProto(it.bytes()).string(1) == "exception.type" }) - val resourceAttrKeys = - parseProto(store.entries[0].bytes).message(1).message(1).all(1).map { parseProto(it.bytes()).string(1) } - assertTrue(resourceAttrKeys.contains("ossdk.install_id")) - } -} diff --git a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/OtlpLogEncoderTest.kt b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/OtlpLogEncoderTest.kt deleted file mode 100644 index 1a572b8f0..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/OtlpLogEncoderTest.kt +++ /dev/null @@ -1,146 +0,0 @@ -package com.onesignal.logger - -import com.onesignal.logger.otlp.EncodableRecord -import com.onesignal.logger.otlp.OtlpLogEncoder -import kotlin.test.Test -import kotlin.test.assertEquals -import kotlin.test.assertTrue - -class OtlpLogEncoderTest { - @Test - fun encodesValidOtlpProtobufStructure() { - val bytes = - OtlpLogEncoder.encode( - resourceAttributes = mapOf("ossdk.install_id" to "abc", "os.name" to "Android"), - records = - listOf( - EncodableRecord( - severity = LogSeverity.ERROR, - body = "boom", - attributes = mapOf("log.level" to "ERROR", "log.message" to "boom"), - timeUnixNanos = 1700000000000000000L, - ), - ), - ) - - // ExportLogsServiceRequest.resource_logs (1) -> ResourceLogs - val resourceLogs = parseProto(bytes).message(1) - - // ResourceLogs.resource (1) -> Resource, attributes (1) repeated KeyValue, sorted by key. - val resourceAttrs = resourceLogs.message(1).all(1).map { parseProto(it.bytes()) } - // os.name sorts before ossdk.install_id - assertEquals("os.name", resourceAttrs[0].string(1)) - assertEquals("Android", resourceAttrs[0].message(2).string(1)) - assertEquals("ossdk.install_id", resourceAttrs[1].string(1)) - - // ResourceLogs.scope_logs (2) -> ScopeLogs - val scopeLogs = resourceLogs.message(2) - assertEquals("OneSignalDeviceSDK", scopeLogs.message(1).string(1)) - - // ScopeLogs.log_records (2) -> LogRecord - val record = scopeLogs.message(2) - // time_unix_nano (1, fixed64) and observed_time_unix_nano (11, fixed64) - assertEquals(1700000000000000000L, record.first(1).varint) - assertEquals(1700000000000000000L, record.first(11).varint) - // severity_number (2, varint) and severity_text (3, string) - assertEquals(17L, record.first(2).varint) - assertEquals("ERROR", record.string(3)) - // body (5) -> AnyValue.string_value (1) - assertEquals("boom", record.message(5).string(1)) - // attributes (6) repeated, sorted: log.level then log.message - val recordAttrs = record.all(6).map { parseProto(it.bytes()) } - assertEquals("log.level", recordAttrs[0].string(1)) - assertEquals("log.message", recordAttrs[1].string(1)) - assertEquals("boom", recordAttrs[1].message(2).string(1)) - } - - @Test - fun emptyStringValueOmittedToMatchProto3() { - // An empty attribute value must produce an AnyValue with NO string_value field - // set (matching proto3 / the OpenTelemetry marshaler), i.e. an empty message. - val bytes = - OtlpLogEncoder.encode( - resourceAttributes = emptyMap(), - records = - listOf( - EncodableRecord( - severity = LogSeverity.INFO, - body = "b", - attributes = mapOf("empty" to ""), - timeUnixNanos = 1L, - ), - ), - ) - val record = parseProto(bytes).message(1).message(2).message(2) - val attr = parseProto(record.all(6).single().bytes()) - assertEquals("empty", attr.string(1)) - // value (2) is present but its AnyValue message is empty (no field 1). - assertTrue(attr.message(2).all(1).isEmpty()) - } - - @Test - fun severityNumbersMatchOtelScheme() { - assertEquals(1, LogSeverity.TRACE.severityNumber) - assertEquals(5, LogSeverity.DEBUG.severityNumber) - assertEquals(9, LogSeverity.INFO.severityNumber) - assertEquals(13, LogSeverity.WARN.severityNumber) - assertEquals(17, LogSeverity.ERROR.severityNumber) - assertEquals(21, LogSeverity.FATAL.severityNumber) - } - - @Test - fun verboseMapsToTrace() { - assertEquals(LogSeverity.TRACE, LogSeverity.fromLevelName("VERBOSE")) - assertEquals(LogSeverity.INFO, LogSeverity.fromLevelName("nonsense")) - } - - @Test - fun contentTypeIsProtobuf() { - assertEquals("application/x-protobuf", OtlpLogEncoder.CONTENT_TYPE) - } - - @Test - fun truncatesAttributeValuesToLogLimit() { - // Matches the old otel LogLimits.maxAttributeValueLength (32,000). A long - // exception.stacktrace value must be truncated on the wire. - val longValue = "x".repeat(40_000) - val bytes = - OtlpLogEncoder.encode( - resourceAttributes = emptyMap(), - records = - listOf( - EncodableRecord( - severity = LogSeverity.ERROR, - body = "boom", - attributes = mapOf("exception.stacktrace" to longValue), - timeUnixNanos = 1L, - ), - ), - ) - val record = parseProto(bytes).message(1).message(2).message(2) - val attr = parseProto(record.all(6).single().bytes()) - assertEquals("exception.stacktrace", attr.string(1)) - assertEquals(32_000, attr.message(2).string(1).length) - } - - @Test - fun capsAttributeCountToLogLimit() { - // Matches the old otel LogLimits.maxNumberOfAttributes (128). - val manyAttrs = (0 until 200).associate { "k${it.toString().padStart(3, '0')}" to "v$it" } - val bytes = - OtlpLogEncoder.encode( - resourceAttributes = emptyMap(), - records = - listOf( - EncodableRecord( - severity = LogSeverity.INFO, - body = "b", - attributes = manyAttrs, - timeUnixNanos = 1L, - ), - ), - ) - val record = parseProto(bytes).message(1).message(2).message(2) - assertEquals(128, record.all(6).size) - } -} diff --git a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/ProtoTestReader.kt b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/ProtoTestReader.kt deleted file mode 100644 index 7d1621ba9..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/ProtoTestReader.kt +++ /dev/null @@ -1,71 +0,0 @@ -package com.onesignal.logger - -/** - * A tiny, independent protobuf reader used only by tests to verify [ - * com.onesignal.logger.otlp.OtlpLogEncoder] output. It is intentionally written - * separately from the production `ProtoWriter` so the tests validate the wire format - * (field numbers, wire types, values) rather than round-tripping through the same code. - */ -internal class ProtoMessage(private val fields: List) { - fun all(fieldNumber: Int): List = fields.filter { it.number == fieldNumber } - - fun first(fieldNumber: Int): ProtoField = - fields.firstOrNull { it.number == fieldNumber } - ?: error("field $fieldNumber not present (have ${fields.map { it.number }})") - - fun message(fieldNumber: Int): ProtoMessage = parseProto(first(fieldNumber).bytes()) - - fun string(fieldNumber: Int): String = first(fieldNumber).bytes().decodeToString() -} - -internal class ProtoField( - val number: Int, - val wireType: Int, - val varint: Long, - private val raw: ByteArray?, -) { - fun bytes(): ByteArray = raw ?: error("field $number is not length-delimited") -} - -internal fun parseProto(bytes: ByteArray): ProtoMessage { - val fields = ArrayList() - var index = 0 - - fun readVarint(): Long { - var shift = 0 - var result = 0L - while (true) { - val byte = bytes[index++].toInt() and 0xFF - result = result or ((byte.toLong() and 0x7F) shl shift) - if (byte < 0x80) break - shift += 7 - } - return result - } - - fun readFixed(byteCount: Int): Long { - var value = 0L - for (i in 0 until byteCount) { - value = value or ((bytes[index++].toLong() and 0xFF) shl (8 * i)) - } - return value - } - - while (index < bytes.size) { - val tag = readVarint().toInt() - val fieldNumber = tag ushr 3 - when (val wireType = tag and 0x7) { - 0 -> fields.add(ProtoField(fieldNumber, wireType, readVarint(), null)) - 1 -> fields.add(ProtoField(fieldNumber, wireType, readFixed(Long.SIZE_BYTES), null)) - 2 -> { - val length = readVarint().toInt() - val slice = bytes.copyOfRange(index, index + length) - index += length - fields.add(ProtoField(fieldNumber, wireType, 0, slice)) - } - 5 -> fields.add(ProtoField(fieldNumber, wireType, readFixed(Int.SIZE_BYTES), null)) - else -> error("unsupported wire type $wireType") - } - } - return ProtoMessage(fields) -} diff --git a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/TestFakes.kt b/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/TestFakes.kt deleted file mode 100644 index 9b31374ef..000000000 --- a/OneSignalSDK/onesignal/logger/src/commonTest/kotlin/com/onesignal/logger/TestFakes.kt +++ /dev/null @@ -1,93 +0,0 @@ -package com.onesignal.logger - -import kotlinx.coroutines.sync.Mutex -import kotlinx.coroutines.sync.withLock - -/** - * Simple in-memory fakes used across the logger module's common tests. - */ - -internal class FakePlatformProvider( - override var appId: String? = "app-123", - override var onesignalId: String? = "os-456", - override var pushSubscriptionId: String? = "push-789", - override var appState: String = "foreground", - override var processUptime: Long = 1234L, - override var currentThreadName: String = "test-thread", - override var enabledFeatureFlags: List = emptyList(), - override var crashStoragePath: String = "/tmp/onesignal/crashes", - override var minFileAgeForReadMillis: Long = 5_000L, - override var isRemoteLoggingEnabled: Boolean = true, - override var remoteLogLevel: String? = "ERROR", - override var isExporterLoggingEnabled: Boolean = false, - override var apiBaseUrl: String = "https://api.onesignal.com/", -) : ILoggerPlatformProvider { - var installId: String = "install-abc" - - override suspend fun getInstallId(): String = installId - override val sdkBase: String = "android" - override val sdkBaseVersion: String = "5.9.5" - override val appPackageId: String = "com.example.app" - override val appVersion: String = "1.0.0" - override val deviceManufacturer: String = "TestCo" - override val deviceModel: String = "Pixel-Test" - override val osName: String = "Android" - override val osVersion: String = "14" - override val osBuildId: String = "BUILD123" - override val sdkWrapper: String? = null - override val sdkWrapperVersion: String? = null - override val appIdForHeaders: String get() = appId ?: "" -} - -internal class FakeHttpSender( - var responses: ArrayDeque = ArrayDeque(), - var defaultResponse: LogHttpResponse = LogHttpResponse(success = true, statusCode = 200), -) : ILogHttpSender { - private val mutex = Mutex() - val sentRequests = mutableListOf() - - override suspend fun send(request: LogHttpRequest): LogHttpResponse { - mutex.withLock { sentRequests.add(request) } - return if (responses.isNotEmpty()) responses.removeFirst() else defaultResponse - } - - fun lastBodyAsString(): String = sentRequests.last().body.decodeToString() -} - -internal class FakeFileStore : ILogFileStore { - data class Entry(val id: String, val bytes: ByteArray, val ageMillis: Long) - - val entries = mutableListOf() - val deletedIds = mutableListOf() - private var counter = 0 - - /** Age assigned to records saved via [save]; tests can tweak per scenario. */ - var savedAgeMillis: Long = Long.MAX_VALUE - - override fun save(bytes: ByteArray) { - entries.add(Entry("file-${counter++}", bytes, savedAgeMillis)) - } - - fun seed(id: String, bytes: ByteArray, ageMillis: Long) { - entries.add(Entry(id, bytes, ageMillis)) - } - - override suspend fun listReadable(minAgeMillis: Long): List = - entries - .filter { it.ageMillis >= minAgeMillis && it.id !in deletedIds } - .map { StoredLogFile(it.id, it.bytes) } - - override suspend fun delete(id: String) { - deletedIds.add(id) - entries.removeAll { it.id == id } - } -} - -internal class RecordingLogger : ILogger { - val messages = mutableListOf() - - override fun error(message: String) { messages.add("E:$message") } - override fun warn(message: String) { messages.add("W:$message") } - override fun info(message: String) { messages.add("I:$message") } - override fun debug(message: String) { messages.add("D:$message") } -} diff --git a/OneSignalSDK/onesignal/logger/src/iosMain/kotlin/com/onesignal/logger/internal/Platform.ios.kt b/OneSignalSDK/onesignal/logger/src/iosMain/kotlin/com/onesignal/logger/internal/Platform.ios.kt deleted file mode 100644 index 7b934a6d0..000000000 --- a/OneSignalSDK/onesignal/logger/src/iosMain/kotlin/com/onesignal/logger/internal/Platform.ios.kt +++ /dev/null @@ -1,11 +0,0 @@ -package com.onesignal.logger.internal - -import platform.Foundation.NSDate -import platform.Foundation.NSUUID -import platform.Foundation.timeIntervalSince1970 - -internal actual fun epochNanosNow(): Long = (NSDate().timeIntervalSince1970 * NANOS_PER_SECOND).toLong() - -internal actual fun randomUuidString(): String = NSUUID().UUIDString().lowercase() - -private const val NANOS_PER_SECOND = 1_000_000_000.0 diff --git a/OneSignalSDK/settings.gradle b/OneSignalSDK/settings.gradle index 6788ccb87..291fc2940 100644 --- a/OneSignalSDK/settings.gradle +++ b/OneSignalSDK/settings.gradle @@ -33,4 +33,10 @@ include ':OneSignal:location' include ':OneSignal:notifications' include ':OneSignal:testhelpers' include ':OneSignal:otel' + +// The :logger module is a Kotlin Multiplatform module maintained in the standalone +// OneSignal-KMP-SDK repo and consumed here as a git submodule (pinned commit). Its +// sources live outside this Gradle tree, so we remap the project directory. Run +// `git submodule update --init --recursive` after cloning, or the path won't exist. include ':OneSignal:logger' +project(':OneSignal:logger').projectDir = new File(settingsDir, '../OneSignal-KMP-SDK/logger') diff --git a/README.md b/README.md index f1621c78b..33bb722b4 100644 --- a/README.md +++ b/README.md @@ -38,5 +38,7 @@ For account issues and support please contact OneSignal support from the [OneSig #### Demo Project To make things easier, we have published demo projects in the `/Examples` folder of this repository. +> **Building from source?** This repository uses a git submodule for the shared Kotlin Multiplatform `:logger` module. Clone with `git clone --recurse-submodules ...`, or run `git submodule update --init --recursive` after cloning, before building. See [CONTRIBUTING.md](CONTRIBUTING.md#building-the-sdk-from-source) for details. + #### Supports: * Tested from Android 5.0 (API level 21) to Android 14 (34) \ No newline at end of file From 310c229a7d2dc15d18a0a4d506e3400a64b7f47f Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 21 Jul 2026 19:23:28 +0530 Subject: [PATCH 04/15] chore(logger): [SDK-4835] bump OneSignal-KMP-SDK submodule to merged main Re-point the logger submodule from the feature-branch commit to the squash-merged main commit (503d610, PR #1). Logger sources are identical to the previously verified commit; :OneSignal:logger:testDebugUnitTest stays green. 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 f42169675..503d61083 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit f4216967533c9e23097fa1bf1ecf29de02d9e9d2 +Subproject commit 503d61083bdda1f33b68d0130a8a5c9bb33ec97e From 3665abff5d2ad555d25924302d455b2b7c9f9860 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 21 Jul 2026 19:48:10 +0530 Subject: [PATCH 05/15] fix(logger): adapt Android host to sync crash API and save(): Boolean MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to OneSignal-KMP-SDK#3 — FileLogStore reports persistence success, and crash/ANR handlers call saveCrash synchronously. Co-authored-by: Cursor --- .../logging/logger/android/AndroidLogAnrDetector.kt | 3 +-- .../logging/logger/android/AndroidLogCrashHandler.kt | 3 +-- .../debug/internal/logging/logger/android/FileLogStore.kt | 8 +++++--- 3 files changed, 7 insertions(+), 7 deletions(-) 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 4a376372a..615a85de9 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 @@ -7,7 +7,6 @@ import com.onesignal.logger.CrashData import com.onesignal.logger.ILogAnrDetector import com.onesignal.logger.ILogCrashReporter import com.onesignal.logger.ILogger -import kotlinx.coroutines.runBlocking import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicLong @@ -113,7 +112,7 @@ internal class AndroidLogAnrDetector( exceptionMessage = "Application Not Responding: Main thread blocked for ${unresponsiveDurationMs}ms", stacktrace = stackTrace.joinToString("\n") { it.toString() }, ) - runBlocking { crashReporter.saveCrash(crash) } + crashReporter.saveCrash(crash) logger.info("$TAG: ANR report saved") } catch (t: Throwable) { logger.error("$TAG: failed to report ANR: ${t.message}") diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt index 1cf7c8bc8..bc73595a5 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 @@ -4,7 +4,6 @@ import com.onesignal.logger.CrashData import com.onesignal.logger.ILogCrashHandler import com.onesignal.logger.ILogCrashReporter import com.onesignal.logger.ILogger -import kotlinx.coroutines.runBlocking /** * Android [ILogCrashHandler] — installs a [Thread.UncaughtExceptionHandler] that @@ -68,7 +67,7 @@ internal class AndroidLogCrashHandler( logger.info("AndroidLogCrashHandler: OneSignal-related crash detected, saving report") try { - runBlocking { crashReporter.saveCrash(throwable.toCrashData(thread)) } + crashReporter.saveCrash(throwable.toCrashData(thread)) } catch (t: Throwable) { logger.error("AndroidLogCrashHandler: failed to save crash report: ${t.message}") } 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 189ad24c0..a65d87be9 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,8 @@ internal class FileLogStore( } @Suppress("TooGenericExceptionCaught", "SwallowedException") - override fun save(bytes: ByteArray) { - try { + override fun save(bytes: ByteArray): Boolean { + return try { val dir = rootDir if (!dir.exists()) dir.mkdirs() // Write to a temp file then rename so a half-written file is never readable. @@ -39,8 +39,10 @@ internal class FileLogStore( target.writeBytes(bytes) temp.delete() } + true } catch (t: Throwable) { - // Crash-path safety: never throw from persistence. + // Crash-path safety: never throw from persistence; signal failure to caller. + false } } From 2ead004f71d7a925f1ec89af40824b338c0fb009 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Tue, 21 Jul 2026 21:31:51 +0530 Subject: [PATCH 06/15] ci: [SDK-4835] add reusable workflow to bump KMP submodule and open PR Reusable (workflow_call) job the OneSignal-KMP-SDK Release workflow invokes to re-point the submodule gitlink to a released tag and open a bump PR here. Mirrors the OneSignal/sdk-shared fan-out pattern and authenticates with the shared org GH_PUSH_TOKEN, so no dedicated GitHub App is needed. Co-authored-by: Cursor --- .github/workflows/bump-kmp-submodule.yml | 106 +++++++++++++++++++++++ 1 file changed, 106 insertions(+) create mode 100644 .github/workflows/bump-kmp-submodule.yml diff --git a/.github/workflows/bump-kmp-submodule.yml b/.github/workflows/bump-kmp-submodule.yml new file mode 100644 index 000000000..476478e65 --- /dev/null +++ b/.github/workflows/bump-kmp-submodule.yml @@ -0,0 +1,106 @@ +name: Bump KMP Submodule + +# Reusable workflow: re-points the OneSignal-KMP-SDK git submodule to a released tag +# and opens a PR. Called by the KMP repo's Release workflow (the fan-out pattern used +# by OneSignal/sdk-shared), or run manually via workflow_dispatch. +# +# Cross-repo callers authenticate by passing the shared org push token as +# GH_PUSH_TOKEN: a workflow triggered from the KMP repo runs with the KMP repo's +# GITHUB_TOKEN, which cannot write here, so GH_PUSH_TOKEN (used across the OneSignal +# SDK repos) is required for the checkout/push/PR to target this repo. + +on: + workflow_call: + inputs: + kmp_version: + description: "OneSignal-KMP-SDK tag to pin the submodule to (e.g. v0.2.0)" + required: true + type: string + kmp_sha: + description: "Commit SHA the tag points at (the gitlink target)" + required: true + type: string + secrets: + GH_PUSH_TOKEN: + required: false + description: Cross-repo push token (contents + pull-requests write) + workflow_dispatch: + inputs: + kmp_version: + description: "OneSignal-KMP-SDK tag to pin the submodule to (e.g. v0.2.0)" + required: true + type: string + kmp_sha: + description: "Commit SHA the tag points at (the gitlink target)" + required: true + type: string + +permissions: + contents: write + pull-requests: write + +jobs: + bump: + runs-on: ubuntu-latest + env: + SUBMODULE_PATH: OneSignal-KMP-SDK + VERSION: ${{ inputs.kmp_version }} + NEW_SHA: ${{ inputs.kmp_sha }} + steps: + - name: "[Checkout] Android SDK" + uses: actions/checkout@v4 + with: + fetch-depth: 0 + token: ${{ secrets.GH_PUSH_TOKEN || github.token }} + # Only the mode-160000 gitlink SHA is rewritten, so the submodule contents + # are not needed (no recursive checkout). + + - name: "[Setup] Git user" + uses: OneSignal/sdk-shared/.github/actions/setup-git-user@main + + - name: "[Bump] Re-point submodule gitlink" + id: bump + run: | + set -euo pipefail + branch="chore/bump-kmp-${VERSION}" + echo "branch=$branch" >> "$GITHUB_OUTPUT" + + git checkout -b "$branch" + # Update the mode-160000 gitlink entry to the commit the tag points at. + git update-index --add --cacheinfo "160000,${NEW_SHA},${SUBMODULE_PATH}" + + # No-op guard: if the pointer already matches (e.g. the very first release, + # where main already points at the tagged commit), skip the commit/PR + # instead of failing on an empty commit. + if git diff --cached --quiet; then + echo "Submodule already at ${NEW_SHA}; nothing to bump." + echo "changed=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "changed=true" >> "$GITHUB_OUTPUT" + git commit -m "chore(logger): bump ${SUBMODULE_PATH} submodule to ${VERSION}" + git push origin "$branch" --force-with-lease + + - name: "[PR] Open bump PR" + if: ${{ steps.bump.outputs.changed == 'true' }} + env: + GH_TOKEN: ${{ secrets.GH_PUSH_TOKEN || github.token }} + run: | + set -euo pipefail + branch="${{ steps.bump.outputs.branch }}" + body="$(printf '%s\n\n%s\n\n%s' \ + "Automated bump of the \`${SUBMODULE_PATH}\` submodule to **${VERSION}** (\`${NEW_SHA}\`)." \ + "Release notes: https://github.com/OneSignal/OneSignal-KMP-SDK/releases/tag/${VERSION}" \ + "After merge, run \`git submodule update --init --recursive\` locally to sync.")" + + # Reuse the PR if one is already open for this branch. + if gh pr view "$branch" >/dev/null 2>&1; then + echo "PR already exists for $branch; branch updated in place." + else + gh pr create \ + --base main \ + --head "$branch" \ + --title "chore(logger): bump shared KMP logger to ${VERSION}" \ + --body "$body" + fi From 2cebef45f6a95d9f1f4b89d036c929038a028243 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 22 Jul 2026 15:29:57 +0530 Subject: [PATCH 07/15] feat(logger): port SDK-4822 app-state-aware ANR + fatal indicator to logger path Brings the KMP logger path to parity with the otel path after merging main (SDK-4822): - AndroidLogAnrDetector is now app-state aware, reusing the shared AnrCheckEvaluator (same tested decision core as OtelAnrDetector): foreground blocks report a fatal ANR via saveCrash; backgrounded blocks report a non-fatal warning via saveNonFatal under a distinct exception type + stack fingerprint, so they stay out of the ANR metric. - Wire isAppInForeground from the platform provider's appState in LoggerLifecycleManager. - Crash/ANR handlers call the synchronous saveCrash/saveNonFatal (no runBlocking), matching the fatal-handler-safe crash API. - Bump OneSignal-KMP-SDK submodule to 9cf1a33 (adds saveNonFatal + ossdk.crash.fatal). Co-authored-by: Cursor --- OneSignal-KMP-SDK | 2 +- .../logger/android/AndroidLogAnrDetector.kt | 150 ++++++++++++++---- .../logger/android/AndroidLogCrashHandler.kt | 2 + .../internal/LoggerLifecycleManager.kt | 4 + 4 files changed, 126 insertions(+), 32 deletions(-) diff --git a/OneSignal-KMP-SDK b/OneSignal-KMP-SDK index 503d61083..9cf1a33db 160000 --- a/OneSignal-KMP-SDK +++ b/OneSignal-KMP-SDK @@ -1 +1 @@ -Subproject commit 503d61083bdda1f33b68d0130a8a5c9bb33ec97e +Subproject commit 9cf1a33dbc3bb95926fc4accd5af2faf48696dc4 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 615a85de9..79aa1b03a 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 @@ -2,29 +2,61 @@ package com.onesignal.debug.internal.logging.logger.android import android.os.Handler import android.os.Looper +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.logger.ILogAnrDetector import com.onesignal.logger.ILogCrashReporter import com.onesignal.logger.ILogger import java.util.concurrent.atomic.AtomicBoolean -import java.util.concurrent.atomic.AtomicLong /** - * Android [ILogAnrDetector] — watchdog that detects when the main thread is blocked - * and, if OneSignal is at fault, persists an ANR report via the `logger` module's - * [ILogCrashReporter]. Direct analogue of `OtelAnrDetector`, but transport-agnostic. + * 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: + * - Foreground block > [anrThresholdMs]: a real, user-visible ANR — reported via + * [ILogCrashReporter.saveCrash] (fatal). + * - Background block > [backgroundThresholdMs]: not an ANR (Android raises none for a + * backgrounded app) — recorded via [ILogCrashReporter.saveNonFatal] under a distinct + * exception type so it stays out of the ANR metric while remaining queryable. + * - If the watchdog's own sleep overran far beyond [checkIntervalMs], the whole process was + * 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. */ internal class AndroidLogAnrDetector( private val crashReporter: ILogCrashReporter, private val logger: ILogger, 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()) private val isMonitoring = AtomicBoolean(false) - private val lastResponseTime = AtomicLong(System.currentTimeMillis()) - private val lastAnrReportTime = AtomicLong(0L) + + private val evaluator = + AnrCheckEvaluator( + anrThresholdMs = anrThresholdMs, + checkIntervalMs = checkIntervalMs, + backgroundThresholdMs = backgroundThresholdMs, + frozenSlackMs = FROZEN_PROCESS_SLACK_MS, + dedupWindowMs = MIN_TIME_BETWEEN_ANR_REPORTS_MS, + // Monotonic clock: SystemClock.uptimeMillis matches the main Looper's scheduling + // clock and can't be skewed by NTP/time changes. + now = { SystemClock.uptimeMillis() }, + ) + private var watchdogThread: Thread? = null private var watchdogRunnable: Runnable? = null private var mainThreadRunnable: Runnable? = null @@ -32,6 +64,7 @@ internal class AndroidLogAnrDetector( private companion object { const val TAG = "AndroidLogAnrDetector" const val MIN_TIME_BETWEEN_ANR_REPORTS_MS = 30_000L + const val FROZEN_PROCESS_SLACK_MS = 2_000L } override fun start() { @@ -39,50 +72,76 @@ internal class AndroidLogAnrDetector( logger.warn("$TAG: already monitoring, skipping start") return } + // Reset the baseline so a gap between construction and start() can't be read as a block. + evaluator.resetBaseline() setupRunnables() - watchdogThread = Thread(watchdogRunnable, "OneSignal-Log-ANR-Watchdog").apply { - isDaemon = true - start() - } + watchdogThread = + Thread(watchdogRunnable, "OneSignal-Log-ANR-Watchdog").apply { + isDaemon = true + start() + } logger.info("$TAG: ANR detection started") } @Suppress("TooGenericExceptionCaught") private fun setupRunnables() { - mainThreadRunnable = Runnable { lastResponseTime.set(System.currentTimeMillis()) } - watchdogRunnable = Runnable { - while (isMonitoring.get()) { - try { - checkForAnr() - } catch (e: InterruptedException) { - break - } catch (t: Throwable) { - logger.error("$TAG: error in watchdog: ${t.message}") + mainThreadRunnable = Runnable { evaluator.recordHeartbeat() } + watchdogRunnable = + Runnable { + while (isMonitoring.get()) { + try { + checkForAnr() + } catch (e: InterruptedException) { + break + } catch (t: Throwable) { + logger.error("$TAG: error in watchdog: ${t.message}") + } } } - } } private fun checkForAnr() { val runnable = mainThreadRunnable ?: return mainHandler.post(runnable) + // Time the sleep itself: if our own thread oversleeps, the process was frozen, not blocked. + val sleepStart = SystemClock.uptimeMillis() Thread.sleep(checkIntervalMs) - val timeSinceLastResponse = System.currentTimeMillis() - lastResponseTime.get() - if (timeSinceLastResponse > anrThresholdMs) { - handleAnrDetected(timeSinceLastResponse) - } else if (lastAnrReportTime.get() > 0) { - lastAnrReportTime.set(0L) - } + evaluateCheck(actualSleepMs = SystemClock.uptimeMillis() - sleepStart) } - private fun handleAnrDetected(timeSinceLastResponse: Long) { - val now = System.currentTimeMillis() - if (now - lastAnrReportTime.get() > MIN_TIME_BETWEEN_ANR_REPORTS_MS) { - lastAnrReportTime.set(now) - reportAnr(timeSinceLastResponse) + private fun evaluateCheck(actualSleepMs: Long) { + when (val result = evaluator.evaluate(actualSleepMs = actualSleepMs, inForeground = resolveForeground())) { + is AnrCheckResult.Responsive -> Unit + is AnrCheckResult.FrozenProcess -> + logger.debug( + "$TAG: 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 ${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 ${result.durationMs}ms while backgrounded — 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 + } + override fun stop() { if (!isMonitoring.getAndSet(false)) { logger.warn("$TAG: not monitoring, skipping stop") @@ -118,4 +177,33 @@ internal class AndroidLogAnrDetector( logger.error("$TAG: failed to report ANR: ${t.message}") } } + + /** + * 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. + */ + @Suppress("TooGenericExceptionCaught") + private fun reportBackgroundBlock(unresponsiveDurationMs: Long) { + try { + val mainThread = Looper.getMainLooper().thread + val stackTrace = mainThread.stackTrace + if (!isOneSignalAtFault(stackTrace)) { + 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) + logger.info("$TAG: background block warning recorded") + } catch (t: Throwable) { + logger.error("$TAG: failed to record background block: ${t.message}") + } + } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt index bc73595a5..631ccf056 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 @@ -67,6 +67,8 @@ internal class AndroidLogCrashHandler( logger.info("AndroidLogCrashHandler: OneSignal-related crash detected, saving report") try { + // Synchronous by contract: the process is about to die, so the crash must reach + // disk before we hand off to the existing handler. Mirrors OtelCrashHandler. crashReporter.saveCrash(throwable.toCrashData(thread)) } catch (t: Throwable) { logger.error("AndroidLogCrashHandler: failed to save crash report: ${t.message}") 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 5f6dcc31d..ae053f305 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 @@ -187,6 +187,10 @@ internal class LoggerLifecycleManager( logger, AnrConstants.DEFAULT_ANR_THRESHOLD_MS, AnrConstants.DEFAULT_CHECK_INTERVAL_MS, + AnrConstants.DEFAULT_BACKGROUND_BLOCK_THRESHOLD_MS, + // Only "background" downgrades a block to a non-fatal warning; "unknown" is + // treated as foreground so a genuine ANR is never silently dropped. + isAppInForeground = { platformProvider.appState != "background" }, ) detector.start() anrDetector = detector From 6fbeae27715091eaf219f676c41e73fa544b52df Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 22 Jul 2026 15:43:05 +0530 Subject: [PATCH 08/15] fix(logger): restore runBlocking around suspend saveCrash/saveNonFatal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Companion to KMP revert of the sync crash API — durability remains the blocking ILogFileStore.save; hosts block the fatal handler until complete. Co-authored-by: Cursor --- .../logging/logger/android/AndroidLogAnrDetector.kt | 5 +++-- .../logging/logger/android/AndroidLogCrashHandler.kt | 7 ++++--- 2 files changed, 7 insertions(+), 5 deletions(-) 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 79aa1b03a..b1dbb0be5 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 @@ -12,6 +12,7 @@ import com.onesignal.logger.ILogAnrDetector import com.onesignal.logger.ILogCrashReporter import com.onesignal.logger.ILogger import java.util.concurrent.atomic.AtomicBoolean +import kotlinx.coroutines.runBlocking /** * Android [ILogAnrDetector] — watchdog that monitors main-thread responsiveness and, if @@ -171,7 +172,7 @@ internal class AndroidLogAnrDetector( exceptionMessage = "Application Not Responding: Main thread blocked for ${unresponsiveDurationMs}ms", stacktrace = stackTrace.joinToString("\n") { it.toString() }, ) - crashReporter.saveCrash(crash) + runBlocking { crashReporter.saveCrash(crash) } logger.info("$TAG: ANR report saved") } catch (t: Throwable) { logger.error("$TAG: failed to report ANR: ${t.message}") @@ -200,7 +201,7 @@ internal class AndroidLogAnrDetector( "Background main-thread block for ${unresponsiveDurationMs}ms | ${buildBlockFingerprint(stackTrace)}", stacktrace = stackTrace.joinToString("\n") { it.toString() }, ) - crashReporter.saveNonFatal(crash) + runBlocking { crashReporter.saveNonFatal(crash) } logger.info("$TAG: background block warning recorded") } catch (t: Throwable) { logger.error("$TAG: failed to record background block: ${t.message}") diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt index 631ccf056..709d7e6bd 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 @@ -4,6 +4,7 @@ import com.onesignal.logger.CrashData import com.onesignal.logger.ILogCrashHandler import com.onesignal.logger.ILogCrashReporter import com.onesignal.logger.ILogger +import kotlinx.coroutines.runBlocking /** * Android [ILogCrashHandler] — installs a [Thread.UncaughtExceptionHandler] that @@ -67,9 +68,9 @@ internal class AndroidLogCrashHandler( logger.info("AndroidLogCrashHandler: OneSignal-related crash detected, saving report") try { - // Synchronous by contract: the process is about to die, so the crash must reach - // disk before we hand off to the existing handler. Mirrors OtelCrashHandler. - crashReporter.saveCrash(throwable.toCrashData(thread)) + // Block until the disk write finishes — the process is about to die. + // Durability comes from ILogFileStore.save; runBlocking bridges the suspend API. + runBlocking { crashReporter.saveCrash(throwable.toCrashData(thread)) } } catch (t: Throwable) { logger.error("AndroidLogCrashHandler: failed to save crash report: ${t.message}") } From 75fcab3c53d77efde619e98a556cf0bac44559bd Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 22 Jul 2026 15:49:50 +0530 Subject: [PATCH 09/15] fix(logger): call sync saveCrash/saveNonFatal without runBlocking Companion to KMP restoring synchronous ILogCrashReporter entry points. Co-authored-by: Cursor --- .../logging/logger/android/AndroidLogAnrDetector.kt | 5 ++--- .../logging/logger/android/AndroidLogCrashHandler.kt | 6 ++---- 2 files changed, 4 insertions(+), 7 deletions(-) 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 b1dbb0be5..79aa1b03a 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 @@ -12,7 +12,6 @@ import com.onesignal.logger.ILogAnrDetector import com.onesignal.logger.ILogCrashReporter import com.onesignal.logger.ILogger import java.util.concurrent.atomic.AtomicBoolean -import kotlinx.coroutines.runBlocking /** * Android [ILogAnrDetector] — watchdog that monitors main-thread responsiveness and, if @@ -172,7 +171,7 @@ internal class AndroidLogAnrDetector( exceptionMessage = "Application Not Responding: Main thread blocked for ${unresponsiveDurationMs}ms", stacktrace = stackTrace.joinToString("\n") { it.toString() }, ) - runBlocking { crashReporter.saveCrash(crash) } + crashReporter.saveCrash(crash) logger.info("$TAG: ANR report saved") } catch (t: Throwable) { logger.error("$TAG: failed to report ANR: ${t.message}") @@ -201,7 +200,7 @@ internal class AndroidLogAnrDetector( "Background main-thread block for ${unresponsiveDurationMs}ms | ${buildBlockFingerprint(stackTrace)}", stacktrace = stackTrace.joinToString("\n") { it.toString() }, ) - runBlocking { crashReporter.saveNonFatal(crash) } + crashReporter.saveNonFatal(crash) logger.info("$TAG: background block warning recorded") } catch (t: Throwable) { logger.error("$TAG: failed to record background block: ${t.message}") diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/android/AndroidLogCrashHandler.kt index 709d7e6bd..8a494a4b9 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 @@ -4,7 +4,6 @@ import com.onesignal.logger.CrashData import com.onesignal.logger.ILogCrashHandler import com.onesignal.logger.ILogCrashReporter import com.onesignal.logger.ILogger -import kotlinx.coroutines.runBlocking /** * Android [ILogCrashHandler] — installs a [Thread.UncaughtExceptionHandler] that @@ -68,9 +67,8 @@ internal class AndroidLogCrashHandler( logger.info("AndroidLogCrashHandler: OneSignal-related crash detected, saving report") try { - // Block until the disk write finishes — the process is about to die. - // Durability comes from ILogFileStore.save; runBlocking bridges the suspend API. - runBlocking { crashReporter.saveCrash(throwable.toCrashData(thread)) } + // Synchronous by contract — process is about to die; write must finish first. + crashReporter.saveCrash(throwable.toCrashData(thread)) } catch (t: Throwable) { logger.error("AndroidLogCrashHandler: failed to save crash report: ${t.message}") } From fba2cd612e01ef2f5caa586231cd23088cc3e7fd Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 22 Jul 2026 16:21:15 +0530 Subject: [PATCH 10/15] ci: inline KMP release notes into the submodule-bump PR The bump PR now embeds the published OneSignal-KMP-SDK release notes for the target tag (fetched best-effort via gh; falls back to linking the release page) so a reviewer can see what the new logger version contains before merging. Re-runs refresh the PR body via `gh pr edit`. Co-authored-by: Cursor --- .github/workflows/bump-kmp-submodule.yml | 36 ++++++++++++++++++------ 1 file changed, 28 insertions(+), 8 deletions(-) diff --git a/.github/workflows/bump-kmp-submodule.yml b/.github/workflows/bump-kmp-submodule.yml index 476478e65..e24be5746 100644 --- a/.github/workflows/bump-kmp-submodule.yml +++ b/.github/workflows/bump-kmp-submodule.yml @@ -82,25 +82,45 @@ jobs: git commit -m "chore(logger): bump ${SUBMODULE_PATH} submodule to ${VERSION}" git push origin "$branch" --force-with-lease - - name: "[PR] Open bump PR" + - name: "[PR] Open or update bump PR" if: ${{ steps.bump.outputs.changed == 'true' }} env: GH_TOKEN: ${{ secrets.GH_PUSH_TOKEN || github.token }} run: | set -euo pipefail branch="${{ steps.bump.outputs.branch }}" - body="$(printf '%s\n\n%s\n\n%s' \ - "Automated bump of the \`${SUBMODULE_PATH}\` submodule to **${VERSION}** (\`${NEW_SHA}\`)." \ - "Release notes: https://github.com/OneSignal/OneSignal-KMP-SDK/releases/tag/${VERSION}" \ - "After merge, run \`git submodule update --init --recursive\` locally to sync.")" + notes_url="https://github.com/OneSignal/OneSignal-KMP-SDK/releases/tag/${VERSION}" - # Reuse the PR if one is already open for this branch. + # Best-effort: pull the published release notes for this tag so the reviewer can + # read exactly what the new version contains before merging the bump, without + # leaving the PR. Reading another repo's release needs cross-repo access, which + # GH_PUSH_TOKEN has; on any failure (missing token, release not yet visible) we + # fall back to just linking the release page. + notes="$(gh release view "$VERSION" --repo OneSignal/OneSignal-KMP-SDK --json body --jq .body 2>/dev/null || true)" + + body_file="$RUNNER_TEMP/bump_pr_body.md" + { + printf 'Automated bump of the `%s` submodule to **%s** (`%s`).\n\n' "$SUBMODULE_PATH" "$VERSION" "$NEW_SHA" + printf '## Release notes — %s\n\n' "$VERSION" + if [ -n "$notes" ]; then + printf '%s\n\n' "$notes" + printf '_Source: [%s](%s)_\n\n' "$VERSION" "$notes_url" + else + printf 'See [%s](%s).\n\n' "$VERSION" "$notes_url" + fi + printf 'After merge, run `git submodule update --init --recursive` locally to sync.\n' + } > "$body_file" + + # Reuse the PR if one is already open for this branch (re-runs refresh title/body). if gh pr view "$branch" >/dev/null 2>&1; then - echo "PR already exists for $branch; branch updated in place." + echo "PR already exists for $branch; refreshing title and release notes." + gh pr edit "$branch" \ + --title "chore(logger): bump shared KMP logger to ${VERSION}" \ + --body-file "$body_file" else gh pr create \ --base main \ --head "$branch" \ --title "chore(logger): bump shared KMP logger to ${VERSION}" \ - --body "$body" + --body-file "$body_file" fi From eed5e05c489ed9983a0204dfe84c9f88975bde7d Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Wed, 22 Jul 2026 17:44:16 +0530 Subject: [PATCH 11/15] docs(contributing): document the shared :logger dev + release flows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "Working on the shared :logger module" section explaining the nested two-git model and two Mermaid flowcharts: Flow A (local edit → commit → push → merge to KMP main, no tag) and Flow B (manual Release workflow → tag + Android submodule-bump PR). Co-authored-by: Cursor --- CONTRIBUTING.md | 45 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 45 insertions(+) diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 25d1edd36..b777d61c7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -23,6 +23,51 @@ git submodule update --init --recursive Whenever you pull changes that move the submodule pointer, re-run `git submodule update --init --recursive` to sync your local checkout to the pinned commit. +### Working on the shared `:logger` module + +The `:logger` sources live in the KMP repo and are checked out here, nested, at `OneSignal-KMP-SDK/`. This means **two gits are in play**: + +- Commands run from the **Android repo root** act on Android and only record the submodule *pointer* (a `160000` gitlink SHA) — not the logger source. +- Commands run from **inside `OneSignal-KMP-SDK/`** act on the KMP repo — that's where the actual `.kt` changes are committed. + +Because Gradle reads the module from the submodule folder on disk, your **uncommitted** KMP edits compile straight into the Android app — no push or version bump is needed while iterating. + +There are two distinct flows: everyday development (Flow A) and releasing (Flow B). + +#### Flow A — Local development (edit → commit → push → merge) + +Ends when your change is on KMP `main`. **No tagging and nothing ships to Android here.** + +```mermaid +flowchart TD + A["Clone with --recurse-submodules
(or: git submodule update --init --recursive)"] --> B["cd OneSignal-KMP-SDK
git checkout main && git pull
git checkout -b feat/my-logger-change"] + B --> C["Edit :logger sources"] + C --> D["Build/verify locally (no push)
Android: ./gradlew :OneSignal:core:compileDebugKotlin
KMP tests: ./gradlew :logger:testDebugUnitTest :logger:iosSimulatorArm64Test spotlessCheck"] + D -->|iterate| C + D --> E["Inside the submodule: commit + push
cd OneSignal-KMP-SDK
git add -A && git commit -m 'feat(logger): ...'
git push -u origin feat/my-logger-change"] + E --> F["Open KMP PR — CI runs (Spotless + unit tests)"] + F --> G["Merge to KMP main"] + G --> H(["Change is on KMP main — still NO tag"]) +``` + +> The submodule starts in **detached HEAD** at the pinned commit. Run `git checkout main` (or `-b `) inside `OneSignal-KMP-SDK/` before committing, or your commit will be orphaned. + +#### Flow B — Release & pin Android to a tag + +Run this when a merged KMP `main` should actually ship into the Android (and later iOS) SDK. **Manual trigger**; Android itself is never tagged — only its submodule *pointer* moves to the KMP tag. + +```mermaid +flowchart TD + A["KMP main has the change(s) to release"] --> B["KMP repo → Actions → 'Release' → Run workflow
pick bump: patch / minor / major"] + B --> C["Release workflow (automatic):
verify → compute next vX.Y.Z
→ create tag + GitHub Release (auto notes)
→ call Android bump workflow (GH_PUSH_TOKEN)"] + C --> D["Android PR opens: 'bump submodule → vX.Y.Z'
moves the pointer to the tag's commit
body includes the KMP release notes · NOT auto-merged"] + D --> E["YOU: review the notes + merge the Android PR"] + E --> F["Sync local Android checkout:
git pull && git submodule update --init --recursive"] + F --> G(["Android pinned to KMP tag vX.Y.Z"]) +``` + +The only manual actions in Flow B are **Run workflow** and **merge the bump PR**; everything in between is automated. + #### Before Submitting A Bug Report Before creating bug reports, please check this list of steps to follow. From 0ad4bb90b632df459ce4b963d603611800c6ac55 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Fri, 24 Jul 2026 00:31:28 +0530 Subject: [PATCH 12/15] ci: fetch OneSignal-KMP-SDK tags before Maven publish Needed so the logger module can stamp and verify vX.Y.* provenance under actions/checkout's shallow submodule clone. Co-authored-by: Cursor --- .github/workflows/publish-release.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.github/workflows/publish-release.yml b/.github/workflows/publish-release.yml index f96a991cb..ead127d6f 100644 --- a/.github/workflows/publish-release.yml +++ b/.github/workflows/publish-release.yml @@ -43,6 +43,14 @@ jobs: ref: ${{ github.event.inputs.ref || github.sha }} submodules: recursive + # actions/checkout does not fetch tag refs into submodules (fetch-depth: 1). + # The shared logger stamps LoggerBuildInfo.KMP_VERSION via `git describe --tags` + # and refuses to publish without a vX.Y.* provenance — so tags must be present. + - name: Fetch OneSignal-KMP-SDK tags (logger provenance) + run: | + git -C OneSignal-KMP-SDK fetch --tags --force + echo "KMP provenance: $(git -C OneSignal-KMP-SDK describe --tags --always --dirty)" + - name: Detect current branch id: detect_branch run: | From f63fb6d065758f492be31600afa06d5463a0e11b Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Fri, 24 Jul 2026 01:08:24 +0530 Subject: [PATCH 13/15] style(logger): fix spotless continuation indent in AndroidLogAnrDetector Align the wrapped exceptionMessage argument to satisfy spotlessCheck. Co-authored-by: Cursor --- .../internal/logging/logger/android/AndroidLogAnrDetector.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 79aa1b03a..e409959db 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 @@ -197,7 +197,7 @@ internal class AndroidLogAnrDetector( threadName = mainThread.name, exceptionType = "BackgroundMainThreadBlockException", exceptionMessage = - "Background main-thread block for ${unresponsiveDurationMs}ms | ${buildBlockFingerprint(stackTrace)}", + "Background main-thread block for ${unresponsiveDurationMs}ms | ${buildBlockFingerprint(stackTrace)}", stacktrace = stackTrace.joinToString("\n") { it.toString() }, ) crashReporter.saveNonFatal(crash) From 38d85c3636744bda392ba020205733024064b855 Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Fri, 24 Jul 2026 00:41:16 +0530 Subject: [PATCH 14/15] feat(logger): [SDK-4835] gate otel-vs-logger switch on SDK_CUSTOM_LOGGING flag Replace the hardcoded LoggerModuleSwitch.USE_LOGGER_MODULE boolean with a remote-config-driven decision. Add the SDK_CUSTOM_LOGGING feature flag (APP_STARTUP activation) and resolve it during early init directly from the cached config in SharedPreferences via OtelIdResolver.resolveCustomLoggingEnabled(), so toggling the flag takes effect on the next app start rather than mid-session. Add unit tests covering resolveCustomLoggingEnabled() (present/absent, case-insensitive, empty/missing flags, no config, null context, invalid JSON), the LoggerModuleSwitch.useLoggerModule() end-to-end read, and the SDK_CUSTOM_LOGGING enum key + activation mode. Co-authored-by: Cursor --- .../core/internal/features/FeatureFlag.kt | 12 ++++ .../core/internal/features/FeatureManager.kt | 4 ++ .../crash/OneSignalCrashUploaderWrapper.kt | 2 +- .../logging/logger/LoggerModuleSwitch.kt | 26 +++++--- .../logging/otel/android/OtelIdResolver.kt | 17 +++++ .../internal/LoggerLifecycleManager.kt | 5 +- .../com/onesignal/internal/OneSignalImp.kt | 2 +- .../internal/features/FeatureFlagTests.kt | 7 ++ .../logging/logger/LoggerModuleSwitchTest.kt | 63 ++++++++++++++++++ .../otel/android/OtelIdResolverTest.kt | 65 +++++++++++++++++++ 10 files changed, 191 insertions(+), 12 deletions(-) create mode 100644 OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitchTest.kt diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt index b2b2d6903..4151d4b9b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureFlag.kt @@ -29,6 +29,18 @@ enum class FeatureFlag( "sdk_identity_verification", FeatureActivationMode.IMMEDIATE ), + + /** + * Routes SDK observability (remote logging, crash capture/upload, ANR detection) through the + * multiplatform `logger` module instead of the legacy OpenTelemetry `otel` module. + * + * APP_STARTUP: the module choice is latched for the whole process and a remote change only + * takes effect on the next app start — switching observability pipelines mid-session is unsafe. + */ + SDK_CUSTOM_LOGGING( + "sdk_custom_logging", + FeatureActivationMode.APP_STARTUP + ), ; /** diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt index c67e563b9..476835cc1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/features/FeatureManager.kt @@ -170,6 +170,10 @@ internal class FeatureManager( // SDK_IDENTITY_VERIFICATION has no side effect: IdentityVerificationService // reads featureStates directly via isEnabled() at gate-check time. FeatureFlag.SDK_IDENTITY_VERIFICATION -> {} + // SDK_CUSTOM_LOGGING has no side effect here: the observability module choice is + // made during early init (before this manager exists) by reading the cached flag + // straight from prefs via LoggerModuleSwitch/OtelIdResolver. + FeatureFlag.SDK_CUSTOM_LOGGING -> {} } } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt index 65f251437..fb3ed3a0b 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashUploaderWrapper.kt @@ -66,7 +66,7 @@ internal class OneSignalCrashUploaderWrapper( if (!OtelSdkSupport.isSupported) return OneSignalDispatchers.launchOnIO { try { - if (LoggerModuleSwitch.USE_LOGGER_MODULE) { + if (LoggerModuleSwitch.useLoggerModule(applicationService.appContext)) { loggerUploader.start() } else { otelUploader.start() diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt index f02d9e8a1..e65a7e297 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitch.kt @@ -1,18 +1,28 @@ package com.onesignal.debug.internal.logging.logger +import android.content.Context +import com.onesignal.debug.internal.logging.otel.android.OtelIdResolver + /** - * Single switch that routes the SDK's observability (remote logging, crash capture, - * crash upload, ANR detection) through either: + * Routes the SDK's observability (remote logging, crash capture, crash upload, ANR detection) + * through either: * - the legacy OpenTelemetry-based `otel` module (default), or * - the new, multiplatform, OpenTelemetry-free `logger` module. * - * Flip [USE_LOGGER_MODULE] to `true` to exercise the `logger` module end-to-end on - * Android. Keeping it `false` by default leaves the existing otel path — and all of - * its tests — completely unchanged, so the swap is a one-line, low-risk toggle. + * The choice is driven by the [com.onesignal.core.internal.features.FeatureFlag.SDK_CUSTOM_LOGGING] + * remote feature flag, read from the cached config in SharedPreferences via [OtelIdResolver]. Because + * the value comes from the config the *previous* session persisted, enabling/disabling the flag takes + * effect on the next app start — never mid-session (the flag is [FeatureActivationMode.APP_STARTUP]). + * + * The flag is read directly from prefs (not through [com.onesignal.core.internal.features.FeatureManager]) + * because the module decision is made during early init, before service bootstrap. It is read fresh on + * each call — consumers ([com.onesignal.internal.LoggerLifecycleManager] and the crash uploader) all run + * early in the same init pass, before the first remote config fetch can change the persisted value, so + * they resolve to the same module for the session. * - * Once the `logger` module has been validated in production, the otel path (and this - * switch) can be removed along with the `:otel` module. + * Once the `logger` module has been validated in production, the otel path (and this switch) can be + * removed along with the `:otel` module. */ internal object LoggerModuleSwitch { - const val USE_LOGGER_MODULE = true + fun useLoggerModule(context: Context): Boolean = OtelIdResolver(context).resolveCustomLoggingEnabled() } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt index b205fffd9..ef400f2ab 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolver.kt @@ -2,7 +2,9 @@ package com.onesignal.debug.internal.logging.otel.android import android.content.Context import com.onesignal.common.IDManager +import com.onesignal.common.toList import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.features.FeatureFlag import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.debug.internal.logging.Logging @@ -231,6 +233,21 @@ internal class OtelIdResolver( if (remoteLoggingParams.has("logLevel")) remoteLoggingParams.getString("logLevel") else null ) + /** + * Resolves whether the multiplatform `logger` module should be used instead of `otel`, from + * the [FeatureFlag.SDK_CUSTOM_LOGGING] flag in the cached config's remote feature flags. + * + * Read directly from SharedPreferences (not via ConfigModelStore / FeatureManager) because + * this is consulted during early init, before those services are ready. The cached value is + * whatever the previous session persisted, so toggling the flag remotely takes effect on the + * next app start. Matching is case-insensitive to mirror FeatureManager's canonical keys. + */ + fun resolveCustomLoggingEnabled(): Boolean { + val flags = readConfigModel()?.optJSONArray(ConfigModel::sdkRemoteFeatureFlags.name)?.toList() ?: return false + val target = FeatureFlag.SDK_CUSTOM_LOGGING.key + return flags.any { (it as? String)?.equals(target, ignoreCase = true) == true } + } + /** * Resolves install ID from SharedPreferences. * Returns "InstallId-Null" if not found, "InstallId-NotFound" if there's an error. diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/LoggerLifecycleManager.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/LoggerLifecycleManager.kt index ae053f305..435811043 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/LoggerLifecycleManager.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/LoggerLifecycleManager.kt @@ -28,8 +28,9 @@ import com.onesignal.logger.LoggerFactory * multiplatform, OpenTelemetry-free observability pipeline and reacts to remote config * changes the same way (using the shared [OtelConfig]/[OtelConfigEvaluator]). * - * Only active when [com.onesignal.debug.internal.logging.logger.LoggerModuleSwitch.USE_LOGGER_MODULE] - * is true; otherwise [OtelLifecycleManager] is used instead. + * Only active when [com.onesignal.debug.internal.logging.logger.LoggerModuleSwitch.useLoggerModule] + * resolves true (i.e. the SDK_CUSTOM_LOGGING feature flag is enabled in cached config); + * otherwise [OtelLifecycleManager] is used instead. */ @Suppress("TooManyFunctions") internal class LoggerLifecycleManager( diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt index e89307a28..306dc57b2 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/internal/OneSignalImp.kt @@ -241,7 +241,7 @@ internal class OneSignalImp : IOneSignal, // can be deferred until services have bootstrapped. val featureManagerProvider = { services.getService() } observabilityManager = - if (com.onesignal.debug.internal.logging.logger.LoggerModuleSwitch.USE_LOGGER_MODULE) { + if (com.onesignal.debug.internal.logging.logger.LoggerModuleSwitch.useLoggerModule(context)) { LoggerLifecycleManager(context = context, featureManagerProvider = featureManagerProvider) } else { OtelLifecycleManager(context = context, featureManagerProvider = featureManagerProvider) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt index e2417a3fa..e187f4cf5 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/core/internal/features/FeatureFlagTests.kt @@ -16,4 +16,11 @@ class FeatureFlagTests : FunSpec({ FeatureFlag.SDK_IDENTITY_VERIFICATION.key shouldBe "sdk_identity_verification" FeatureFlag.SDK_IDENTITY_VERIFICATION.activationMode shouldBe FeatureActivationMode.IMMEDIATE } + + test("SDK_CUSTOM_LOGGING uses the expected remote key and APP_STARTUP activation") { + // APP_STARTUP so the observability module choice is latched per process and a remote + // change only takes effect on the next app start. + FeatureFlag.SDK_CUSTOM_LOGGING.key shouldBe "sdk_custom_logging" + FeatureFlag.SDK_CUSTOM_LOGGING.activationMode shouldBe FeatureActivationMode.APP_STARTUP + } }) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitchTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitchTest.kt new file mode 100644 index 000000000..a03168eaf --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/logger/LoggerModuleSwitchTest.kt @@ -0,0 +1,63 @@ +package com.onesignal.debug.internal.logging.logger + +import android.content.Context +import android.content.SharedPreferences +import androidx.test.core.app.ApplicationProvider +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.features.FeatureFlag +import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys +import com.onesignal.core.internal.preferences.PreferenceStores +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import org.json.JSONArray +import org.json.JSONObject +import com.onesignal.core.internal.config.CONFIG_NAME_SPACE as configNameSpace + +/** + * End-to-end coverage for the otel-vs-logger routing switch. Asserts [LoggerModuleSwitch.useLoggerModule] + * reflects the SDK_CUSTOM_LOGGING flag as persisted in the cached config (the same prefs the previous + * session wrote), which is how the choice is made during early init before service bootstrap. + */ +@RobolectricTest +class LoggerModuleSwitchTest : FunSpec({ + + lateinit var appContext: Context + lateinit var sharedPreferences: SharedPreferences + + fun writeCachedFeatureFlags(vararg flags: String) { + val configModel = JSONObject().apply { + put(ConfigModel::sdkRemoteFeatureFlags.name, JSONArray().apply { flags.forEach { put(it) } }) + } + val configArray = JSONArray().apply { put(configModel) } + sharedPreferences.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, configArray.toString()) + .commit() + } + + beforeEach { + appContext = ApplicationProvider.getApplicationContext() + sharedPreferences = appContext.getSharedPreferences(PreferenceStores.ONESIGNAL, Context.MODE_PRIVATE) + sharedPreferences.edit().clear().commit() + } + + afterEach { + sharedPreferences.edit().clear().commit() + } + + test("useLoggerModule returns true when SDK_CUSTOM_LOGGING is cached") { + writeCachedFeatureFlags(FeatureFlag.SDK_CUSTOM_LOGGING.key) + + LoggerModuleSwitch.useLoggerModule(appContext) shouldBe true + } + + test("useLoggerModule returns false when SDK_CUSTOM_LOGGING is not cached") { + writeCachedFeatureFlags("sdk_identity_verification") + + LoggerModuleSwitch.useLoggerModule(appContext) shouldBe false + } + + test("useLoggerModule returns false when no config is cached") { + LoggerModuleSwitch.useLoggerModule(appContext) shouldBe false + } +}) diff --git a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt index 86be0f189..d43e92b69 100644 --- a/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt +++ b/OneSignalSDK/onesignal/core/src/test/java/com/onesignal/debug/internal/logging/otel/android/OtelIdResolverTest.kt @@ -6,6 +6,7 @@ import androidx.test.core.app.ApplicationProvider import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest import com.onesignal.common.IDManager.LOCAL_PREFIX import com.onesignal.core.internal.config.ConfigModel +import com.onesignal.core.internal.features.FeatureFlag import com.onesignal.core.internal.preferences.PreferenceOneSignalKeys import com.onesignal.core.internal.preferences.PreferenceStores import com.onesignal.debug.LogLevel @@ -1048,4 +1049,68 @@ class OtelIdResolverTest : FunSpec({ appId2 shouldBe "test-app-id" pushId2 shouldBe "test-push-id" } + + // ===== resolveCustomLoggingEnabled Tests ===== + // Reads the SDK_CUSTOM_LOGGING flag from the cached config's sdkRemoteFeatureFlags array. + // Drives the otel-vs-logger observability module choice (via LoggerModuleSwitch) on next launch. + + fun writeConfigWithFeatureFlags(vararg flags: String) { + val configModel = JSONObject().apply { + put(ConfigModel::sdkRemoteFeatureFlags.name, JSONArray().apply { flags.forEach { put(it) } }) + } + writeAndVerifyConfigData(JSONArray().apply { put(configModel) }) + } + + test("resolveCustomLoggingEnabled returns true when sdk_custom_logging flag is present") { + writeConfigWithFeatureFlags(FeatureFlag.SDK_CUSTOM_LOGGING.key) + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe true + } + + test("resolveCustomLoggingEnabled matches the flag case-insensitively") { + writeConfigWithFeatureFlags("SDK_Custom_Logging") + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe true + } + + test("resolveCustomLoggingEnabled returns true when flag is present among other flags") { + writeConfigWithFeatureFlags("sdk_identity_verification", "sdk_custom_logging", "some_other_flag") + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe true + } + + test("resolveCustomLoggingEnabled returns false when flag is absent but others present") { + writeConfigWithFeatureFlags("sdk_identity_verification") + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe false + } + + test("resolveCustomLoggingEnabled returns false when the feature flags array is empty") { + writeConfigWithFeatureFlags() + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe false + } + + test("resolveCustomLoggingEnabled returns false when sdkRemoteFeatureFlags field is missing") { + val configModel = JSONObject().apply { put(ConfigModel::appId.name, "test-app-id") } + writeAndVerifyConfigData(JSONArray().apply { put(configModel) }) + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe false + } + + test("resolveCustomLoggingEnabled returns false when no config exists") { + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe false + } + + test("resolveCustomLoggingEnabled returns false when context is null") { + OtelIdResolver(null).resolveCustomLoggingEnabled() shouldBe false + } + + test("resolveCustomLoggingEnabled handles invalid JSON gracefully") { + sharedPreferences!!.edit() + .putString(PreferenceOneSignalKeys.MODEL_STORE_PREFIX + configNameSpace, "invalid-json") + .commit() + + OtelIdResolver(appContext!!).resolveCustomLoggingEnabled() shouldBe false + } }) From 3b1bd566635a2e61e79c15f56a86f1ff2a7f74da Mon Sep 17 00:00:00 2001 From: AR Abdul Azeez Date: Fri, 24 Jul 2026 00:59:38 +0530 Subject: [PATCH 15/15] chore: opt in to publishing shared KMP module (com.onesignal:kmp) Add the onesignalPublishSharedKmp host opt-in flag so the Android SDK release publishes the shared :kmp module (sourced from the OneSignal-KMP-SDK submodule) to Maven Central, making the com.onesignal:kmp coordinate referenced by :core's POM resolvable for consumers. Co-authored-by: Cursor --- OneSignalSDK/gradle.properties | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/OneSignalSDK/gradle.properties b/OneSignalSDK/gradle.properties index 659c88c17..b13a8dc8a 100644 --- a/OneSignalSDK/gradle.properties +++ b/OneSignalSDK/gradle.properties @@ -41,6 +41,14 @@ android.useAndroidX = true # tested against; the combination works for our targets, so silence the warning. kotlin.mpp.androidGradlePluginCompatibility.nowarn = true +# Opt in to publishing the shared KMP modules (e.g. :kmp, sourced from the +# OneSignal-KMP-SDK submodule) to Maven Central as part of the Android SDK +# release. The shared module's build.gradle only wires up Maven publishing when +# this flag is set, so its coordinate (com.onesignal:kmp, referenced by :core's +# POM) resolves for consumers. The standalone KMP repo build and the iOS host +# (which ships an XCFramework, not a Maven artifact) never set this. +onesignalPublishSharedKmp = true + # This is the name of the SDK to use when building your project. # This will be fed from the GitHub Actions workflow. SDK_VERSION=5.9.7