diff --git a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt index 123e74749..f5f78029b 100644 --- a/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt +++ b/Examples/OneSignalDemo/app/src/main/java/com/onesignal/sdktest/application/MainApplicationKT.kt @@ -42,6 +42,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.delay import kotlinx.coroutines.launch class MainApplicationKT : MultiDexApplication() { @@ -81,6 +82,9 @@ class MainApplicationKT : MultiDexApplication() { OneSignal.Notifications.requestPermission(true) Log.d(Tag.LOG_TAG, Text.ONESIGNAL_SDK_INIT) + + delay(3000) + //throw RuntimeException("test crash 2025-11-04 18") } } diff --git a/OneSignalSDK/build.gradle b/OneSignalSDK/build.gradle index bee581d40..d6991ba3b 100644 --- a/OneSignalSDK/build.gradle +++ b/OneSignalSDK/build.gradle @@ -14,7 +14,7 @@ buildscript { huaweiAgconnectVersion = '1.9.1.304' huaweiHMSPushVersion = '6.3.0.304' huaweiHMSLocationVersion = '4.0.0.300' - kotlinVersion = '1.9.25' + kotlinVersion = '2.2.21' coroutinesVersion = '1.7.3' kotestVersion = '5.8.0' ioMockVersion = '1.13.2' diff --git a/OneSignalSDK/onesignal/core/build.gradle b/OneSignalSDK/onesignal/core/build.gradle index b0f258071..a581fa085 100644 --- a/OneSignalSDK/onesignal/core/build.gradle +++ b/OneSignalSDK/onesignal/core/build.gradle @@ -88,6 +88,18 @@ dependencies { } } + implementation platform("io.opentelemetry:opentelemetry-bom:1.55.0") + + implementation('io.opentelemetry:opentelemetry-api') + implementation('io.opentelemetry:opentelemetry-sdk') + + implementation('io.opentelemetry:opentelemetry-exporter-otlp') // includes okhttp + implementation('io.opentelemetry.semconv:opentelemetry-semconv:1.37.0') + implementation('io.opentelemetry.contrib:opentelemetry-disk-buffering:1.51.0-alpha') + // TODO: Make sure by adding Android OTel that it doesn't auto send stuff to us + + + testImplementation(project(':OneSignal:testhelpers')) testImplementation("io.kotest:kotest-runner-junit5:$kotestVersion") diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt index 9083cddad..ff16b2ae1 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/CoreModule.kt @@ -33,6 +33,19 @@ import com.onesignal.core.internal.purchases.impl.TrackGooglePurchase import com.onesignal.core.internal.startup.IStartableService import com.onesignal.core.internal.time.ITime import com.onesignal.core.internal.time.impl.Time +import com.onesignal.debug.internal.crash.IOneSignalCrashReporter +import com.onesignal.debug.internal.crash.OneSignalCrashHandler +import com.onesignal.debug.internal.logging.otel.crash.IOneSignalCrashConfigProvider +import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetry +import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetryCrash +import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetryRemote +import com.onesignal.debug.internal.logging.otel.crash.OneSignalCrashConfigProvider +import com.onesignal.debug.internal.logging.otel.crash.OneSignalCrashReporterOtel +import com.onesignal.debug.internal.logging.otel.crash.OneSignalCrashUploader +import com.onesignal.debug.internal.logging.otel.OneSignalOpenTelemetryCrashLocal +import com.onesignal.debug.internal.logging.otel.OneSignalOpenTelemetryRemote +import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelFieldsPerEvent +import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelFieldsTopLevel import com.onesignal.inAppMessages.IInAppMessagesManager import com.onesignal.inAppMessages.internal.MisconfiguredIAMManager import com.onesignal.location.ILocationManager @@ -81,6 +94,20 @@ internal class CoreModule : IModule { // Purchase Tracking builder.register().provides() + // Remote Crash and error logging + builder.register().provides() + builder.register().provides() + builder.register().provides() + + builder.register().provides() + builder.register().provides() + + builder.register().provides() + builder.register().provides() + + builder.register().provides() + builder.register().provides() + // Register dummy services in the event they are not configured. These dummy services // will throw an error message if the associated functionality is attempted to be used. builder.register().provides() diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt index 825637f31..29b5578ae 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/http/impl/HttpClient.kt @@ -26,6 +26,9 @@ import java.net.UnknownHostException import java.util.Scanner import javax.net.ssl.HttpsURLConnection +internal const val HTTP_SDK_VERSION_HEADER_KEY = "SDK-Version" +internal val HTTP_SDK_VERSION_HEADER_VALUE = "onesignal/android/${OneSignalUtils.sdkVersion}" + internal class HttpClient( private val _connectionFactory: IHttpConnectionFactory, private val _prefs: IPreferencesService, @@ -131,7 +134,7 @@ internal class HttpClient( con.useCaches = false con.connectTimeout = timeout con.readTimeout = timeout - con.setRequestProperty("SDK-Version", "onesignal/android/" + OneSignalUtils.sdkVersion) + con.setRequestProperty(HTTP_SDK_VERSION_HEADER_KEY, HTTP_SDK_VERSION_HEADER_VALUE) if (OneSignalWrapper.sdkType != null && OneSignalWrapper.sdkVersion != null) { con.setRequestProperty("SDK-Wrapper", "onesignal/${OneSignalWrapper.sdkType}/${OneSignalWrapper.sdkVersion}") diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/ITime.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/ITime.kt index ff35096ef..8f1824d48 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/ITime.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/ITime.kt @@ -10,4 +10,9 @@ interface ITime { * current time and midnight, January 1, 1970 UTC). */ val currentTimeMillis: Long + + /** + * Returns how long the app has been running. + */ + val processUptimeMillis: Long } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/impl/Time.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/impl/Time.kt index 231f37edf..753ef124d 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/impl/Time.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/time/impl/Time.kt @@ -1,8 +1,14 @@ package com.onesignal.core.internal.time.impl +import android.os.Build +import android.os.SystemClock +import androidx.annotation.RequiresApi import com.onesignal.core.internal.time.ITime internal class Time : ITime { override val currentTimeMillis: Long get() = System.currentTimeMillis() + override val processUptimeMillis: Long + @RequiresApi(Build.VERSION_CODES.N) + get() = SystemClock.uptimeMillis() - android.os.Process.getStartUptimeMillis() } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashReporter.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashReporter.kt new file mode 100644 index 000000000..c51391c3d --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/IOneSignalCrashReporter.kt @@ -0,0 +1,5 @@ +package com.onesignal.debug.internal.crash + +internal interface IOneSignalCrashReporter { + suspend fun saveCrash(thread: Thread, throwable: Throwable) +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt new file mode 100644 index 000000000..65d580a42 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt @@ -0,0 +1,66 @@ +package com.onesignal.debug.internal.crash + +import com.onesignal.core.internal.startup.IStartableService +import kotlinx.coroutines.runBlocking + +/** + * Purpose: Writes any crashes involving OneSignal to a file where they can + * later be send to OneSignal to help improve reliability. + * NOTE: For future refactors, code is written assuming this is a singleton + */ +internal class OneSignalCrashHandler( + private val _crashReporter: IOneSignalCrashReporter, +) : IStartableService, + Thread.UncaughtExceptionHandler { + private var existingHandler: Thread.UncaughtExceptionHandler? = null + private val seenThrowables: MutableList = mutableListOf() + + override fun start() { + existingHandler = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler(this) + } + + override fun uncaughtException(thread: Thread, throwable: Throwable) { + // Ensure we never attempt to process the same throwable instance + // more than once. This would only happen if there was another crash + // handler and was faulty in a specific way. + synchronized(seenThrowables) { + if (seenThrowables.contains(throwable)) { + return + } + seenThrowables.add(throwable) + } + + // TODO: Catch anything we may throw and print only to logcat + // TODO: Also send a stop command to OneSignalCrashUploader, + // give a bit of time to finish and then call existingHandler. + // * This way the app doesn't have to open a 2nd time to get the + // crash report and should help prevent duplicated reports. + if (!isOneSignalAtFault(throwable)) { + existingHandler?.uncaughtException(thread, throwable) + return + } + + /** + * NOTE: The order and running sequentially is important as: + * The existingHandler.uncaughtException can immediately terminate the + * process, either directly (if this is Android's + * KillApplicationHandler) OR the app's handler / 3rd party SDK (either + * directly or more likely, by it calling Android's + * KillApplicationHandler). + * Given this, we can't parallelize the existingHandler work with ours. + * The safest thing is to try to finish our work as fast as possible + * (including ensuring our logging write buffers are flushed) then call + * the existingHandler so any crash handlers the app also has gets the + * crash even too. + * + * NOTE: addShutdownHook() isn't a workaround as it doesn't fire for + * Process.killProcess, which KillApplicationHandler calls. + */ + runBlocking { _crashReporter.saveCrash(thread, throwable) } + existingHandler?.uncaughtException(thread, throwable) + } +} + +internal fun isOneSignalAtFault(throwable: Throwable): Boolean = + throwable.stackTrace.any { it.className.startsWith("com.onesignal") } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt new file mode 100644 index 000000000..2dd559030 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/IOneSignalOpenTelemetry.kt @@ -0,0 +1,17 @@ +package com.onesignal.debug.internal.logging.otel + +import io.opentelemetry.api.logs.LogRecordBuilder +import io.opentelemetry.sdk.common.CompletableResultCode +import io.opentelemetry.sdk.logs.export.LogRecordExporter + +internal interface IOneSignalOpenTelemetry { + suspend fun getLogger(): LogRecordBuilder + + suspend fun forceFlush(): CompletableResultCode +} + +internal interface IOneSignalOpenTelemetryCrash : IOneSignalOpenTelemetry + +internal interface IOneSignalOpenTelemetryRemote : IOneSignalOpenTelemetry { + val logExporter: LogRecordExporter +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt new file mode 100644 index 000000000..f0a82c505 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt @@ -0,0 +1,115 @@ +package com.onesignal.debug.internal.logging.otel + +import android.os.Build +import androidx.annotation.RequiresApi +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.http.impl.HTTP_SDK_VERSION_HEADER_KEY +import com.onesignal.core.internal.http.impl.HTTP_SDK_VERSION_HEADER_VALUE +import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelFieldsPerEvent +import com.onesignal.debug.internal.logging.otel.attributes.OneSignalOtelFieldsTopLevel +import com.onesignal.debug.internal.logging.otel.config.OtelConfigCrashFile +import com.onesignal.debug.internal.logging.otel.config.OtelConfigRemoteOneSignal +import com.onesignal.debug.internal.logging.otel.config.OtelConfigShared +import com.onesignal.debug.internal.logging.otel.crash.IOneSignalCrashConfigProvider +import io.opentelemetry.api.logs.LogRecordBuilder +import io.opentelemetry.sdk.OpenTelemetrySdk +import io.opentelemetry.sdk.common.CompletableResultCode +import java.util.concurrent.TimeUnit +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +internal fun LogRecordBuilder.setAllAttributes(attributes: Map): LogRecordBuilder { + attributes.forEach { this.setAttribute(it.key, it.value) } + return this +} + +internal abstract class OneSignalOpenTelemetryBase( + private val _osTopLevelFields: OneSignalOtelFieldsTopLevel, + private val _osPerEventFields: OneSignalOtelFieldsPerEvent, +) : IOneSignalOpenTelemetry { + private val lock = Any() + private var sdkCachedValue: OpenTelemetrySdk? = null + + protected suspend fun getSdk(): OpenTelemetrySdk { + val attributes = _osTopLevelFields.getAttributes() + synchronized(lock) { + var localSdk = sdkCachedValue + if (localSdk != null) { + return localSdk + } + + localSdk = getSdkInstance(attributes) + sdkCachedValue = localSdk + return localSdk + } + } + + protected abstract fun getSdkInstance(attributes: Map): OpenTelemetrySdk + + override suspend fun forceFlush(): CompletableResultCode { + val sdkLoggerProvider = getSdk().sdkLoggerProvider + return suspendCoroutine { + it.resume( + sdkLoggerProvider.forceFlush().join(10, TimeUnit.SECONDS) + ) + } + } + + override suspend fun getLogger(): LogRecordBuilder = + getSdk() + .sdkLoggerProvider + .loggerBuilder("loggerBuilder") + .build() + .logRecordBuilder() + .setAllAttributes(_osPerEventFields.getAttributes()) +} + +@RequiresApi(Build.VERSION_CODES.O) +internal class OneSignalOpenTelemetryRemote( + private val _configModelStore: ConfigModelStore, + _osTopLevelFields: OneSignalOtelFieldsTopLevel, + _osPerEventFields: OneSignalOtelFieldsPerEvent, +) : OneSignalOpenTelemetryBase(_osTopLevelFields, _osPerEventFields), + IOneSignalOpenTelemetryRemote { + val extraHttpHeaders by lazy { + mapOf( + "X-OneSignal-App-Id" to _configModelStore.model.appId, + HTTP_SDK_VERSION_HEADER_KEY to HTTP_SDK_VERSION_HEADER_VALUE, + "x-honeycomb-team" to "", // TODO: REMOVE + ) + } + + override val logExporter by lazy { + OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(extraHttpHeaders) + } + + override fun getSdkInstance(attributes: Map): OpenTelemetrySdk = + OpenTelemetrySdk + .builder() + .setLoggerProvider( + OtelConfigRemoteOneSignal.SdkLoggerProviderConfig.create( + OtelConfigShared.ResourceConfig.create(attributes), + extraHttpHeaders + ) + ).build() +} + +internal class OneSignalOpenTelemetryCrashLocal( + private val _crashPathProvider: IOneSignalCrashConfigProvider, + _osTopLevelFields: OneSignalOtelFieldsTopLevel, + _osPerEventFields: OneSignalOtelFieldsPerEvent, +) : OneSignalOpenTelemetryBase(_osTopLevelFields, _osPerEventFields), + IOneSignalOpenTelemetryCrash { + override fun getSdkInstance(attributes: Map): OpenTelemetrySdk = + OpenTelemetrySdk + .builder() + .setLoggerProvider( + OtelConfigCrashFile.SdkLoggerProviderConfig.create( + OtelConfigShared.ResourceConfig.create( + attributes + ), + _crashPathProvider.path, + _crashPathProvider.minFileAgeForReadMillis, + ) + ).build() +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt new file mode 100644 index 000000000..eeff4da6d --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt @@ -0,0 +1,71 @@ +package com.onesignal.debug.internal.logging.otel.attributes + +import com.onesignal.common.IDManager +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.time.ITime +import com.onesignal.user.internal.identity.IdentityModelStore +import com.squareup.wire.internal.toUnmodifiableMap +import java.util.UUID + +internal class OneSignalOtelFieldsPerEvent( + private val _applicationService: IApplicationService, + private val _configModelStore: ConfigModelStore, + private val _identityModelStore: IdentityModelStore, + private val _time: ITime, +) { + fun getAttributes(): Map { + val attributes: MutableMap = mutableMapOf() + + attributes.put("log.record.uid", recordId.toString()) + + attributes + .putIfValueNotNull( + "$OS_OTEL_NAMESPACE.onesignal_id", + onesignalId + ).putIfValueNotNull( + "$OS_OTEL_NAMESPACE.push_subscription_id", + subscriptionId + ) + + attributes.put("android.app.state", appState) + attributes.put("process.uptime", processUptime.toString()) + attributes.put("thread.name", currentThreadName) + + return attributes.toUnmodifiableMap() + } + + private val onesignalId: String? get() { + val onesignalId = _identityModelStore.model.onesignalId + if (IDManager.isLocalId(onesignalId)) { + return null + } + return onesignalId + } + + private val subscriptionId: String? get() { + val pushSubscriptionId = _configModelStore.model.pushSubscriptionId + if (pushSubscriptionId == null || + IDManager.isLocalId(pushSubscriptionId)) { + return null + } + return pushSubscriptionId + } + + // https://opentelemetry.io/docs/specs/semconv/registry/attributes/android/ + private val appState: String get() = + if (_applicationService.isInForeground) "foreground" else "background" + + // https://opentelemetry.io/docs/specs/semconv/system/process-metrics/#metric-processuptime + private val processUptime: Double get() = + _time.processUptimeMillis / 1_000.toDouble() + + // https://opentelemetry.io/docs/specs/semconv/general/attributes/#general-thread-attributes + private val currentThreadName: String get() = + Thread.currentThread().name + + // idempotency so the backend can filter on duplicate events + // https://opentelemetry.io/docs/specs/semconv/general/logs/#general-log-identification-attributes + private val recordId: UUID get() = + UUID.randomUUID() +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsTopLevel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsTopLevel.kt new file mode 100644 index 000000000..50d929774 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsTopLevel.kt @@ -0,0 +1,71 @@ +package com.onesignal.debug.internal.logging.otel.attributes + +import android.os.Build +import com.onesignal.common.AndroidUtils +import com.onesignal.common.OneSignalUtils +import com.onesignal.common.OneSignalWrapper +import com.onesignal.core.internal.application.IApplicationService +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.device.IInstallIdService +import com.squareup.wire.internal.toUnmodifiableMap + +// Used on all attributes / fields we add to Otel events that is NOT part of +// their spec. We do this to make it clear where the source of this field is. +internal const val OS_OTEL_NAMESPACE: String = "ossdk" + +/** + * Purpose: Fields to be included in every Otel request that goes out. + * Requirements: Only include fields that can NOT change during runtime, + * as these are only fetched once. (Calculated fields are ok) + */ +internal class OneSignalOtelFieldsTopLevel( + private val _applicationService: IApplicationService, + private val _configModelStore: ConfigModelStore, + private val _installIdService: IInstallIdService, +) { + suspend fun getAttributes(): Map { + val attributes: MutableMap = + mutableMapOf( + "$OS_OTEL_NAMESPACE.app_id" to + _configModelStore.model.appId, + "$OS_OTEL_NAMESPACE.install_id" to + _installIdService.getId().toString(), + "$OS_OTEL_NAMESPACE.sdk_base" + to "android", + "$OS_OTEL_NAMESPACE.sdk_base_version" to + OneSignalUtils.sdkVersion, + "$OS_OTEL_NAMESPACE.app_package_id" to + _applicationService.appContext.packageName, + "$OS_OTEL_NAMESPACE.app_version" to + (AndroidUtils.getAppVersion(_applicationService.appContext) ?: "unknown"), + "device.manufacturer" + to Build.MANUFACTURER, + "device.model.identifier" + to Build.MODEL, + "os.name" + to "Android", + "os.version" + to Build.VERSION.RELEASE, + "os.build_id" + to Build.ID, + ) + + attributes + .putIfValueNotNull( + "$OS_OTEL_NAMESPACE.sdk_wrapper", + OneSignalWrapper.sdkType + ).putIfValueNotNull( + "$OS_OTEL_NAMESPACE.sdk_wrapper_version", + OneSignalWrapper.sdkVersion + ) + + return attributes.toUnmodifiableMap() + } +} + +internal fun MutableMap.putIfValueNotNull(key: K, value: V?): MutableMap { + if (value != null) { + this[key] = value + } + return this +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt new file mode 100644 index 000000000..0556d75a5 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigCrashFile.kt @@ -0,0 +1,50 @@ +package com.onesignal.debug.internal.logging.otel.config + +import com.onesignal.debug.internal.logging.otel.config.OtelConfigShared.LogLimitsConfig +import io.opentelemetry.contrib.disk.buffering.exporters.LogRecordToDiskExporter +import io.opentelemetry.contrib.disk.buffering.storage.impl.FileLogRecordStorage +import io.opentelemetry.contrib.disk.buffering.storage.impl.FileStorageConfiguration +import io.opentelemetry.sdk.logs.SdkLoggerProvider +import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor +import io.opentelemetry.sdk.resources.Resource +import java.io.File +import kotlin.time.Duration.Companion.hours + +internal class OtelConfigCrashFile { + internal object SdkLoggerProviderConfig { + fun getFileLogRecordStorage( + rootDir: String, + minFileAgeForReadMillis: Long + ): FileLogRecordStorage = + FileLogRecordStorage.create( + File(rootDir), + FileStorageConfiguration + .builder() + // NOTE: Only use such as small maxFileAgeForWrite for + // crashes, as we want to send them as soon as possible + // without having to wait too long for buffers. + .setMaxFileAgeForWriteMillis(2_000) + .setMinFileAgeForReadMillis(minFileAgeForReadMillis) + .setMaxFileAgeForReadMillis(72.hours.inWholeMilliseconds) + .build() + ) + + fun create( + resource: Resource, + rootDir: String, + minFileAgeForReadMillis: Long, + ): SdkLoggerProvider { + val logToDiskExporter = + LogRecordToDiskExporter + .builder(getFileLogRecordStorage(rootDir, minFileAgeForReadMillis)) + .build() + return SdkLoggerProvider + .builder() + .setResource(resource) + .addLogRecordProcessor( + BatchLogRecordProcessor.builder(logToDiskExporter).build() + ).setLogLimits(LogLimitsConfig::logLimits) + .build() + } + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigRemoteOneSignal.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigRemoteOneSignal.kt new file mode 100644 index 000000000..b6ffb30c5 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigRemoteOneSignal.kt @@ -0,0 +1,57 @@ +package com.onesignal.debug.internal.logging.otel.config + +import android.os.Build +import androidx.annotation.RequiresApi +import com.onesignal.debug.internal.logging.otel.config.OtelConfigRemoteOneSignal.SdkLoggerProviderConfig.BASE_URL +import com.onesignal.debug.internal.logging.otel.config.OtelConfigShared.LogLimitsConfig +import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter +import io.opentelemetry.sdk.logs.SdkLoggerProvider +import io.opentelemetry.sdk.logs.export.LogRecordExporter +import io.opentelemetry.sdk.resources.Resource +import java.time.Duration + +internal class OtelConfigRemoteOneSignal { + object LogRecordExporterConfig { + @RequiresApi(Build.VERSION_CODES.O) + fun otlpHttpLogRecordExporter( + headers: Map, + endpoint: String, + ): LogRecordExporter { + val builder = OtlpHttpLogRecordExporter.builder() + headers.forEach { builder.addHeader(it.key, it.value) } + builder + .setEndpoint(endpoint) + .setTimeout(Duration.ofSeconds(10)) + return builder.build() + } + } + + object SdkLoggerProviderConfig { + // TODO: Switch to https://sdklogs.onesignal.com:443/sdk/otel + const val BASE_URL = "https://api.honeycomb.io:443" + + @RequiresApi(Build.VERSION_CODES.O) + fun create( + resource: Resource, + extraHttpHeaders: Map, + ): SdkLoggerProvider = + SdkLoggerProvider + .builder() + .setResource(resource) + .addLogRecordProcessor( + OtelConfigShared.LogRecordProcessorConfig.batchLogRecordProcessor( + HttpRecordBatchExporter.create(extraHttpHeaders) + ) + ).setLogLimits(LogLimitsConfig::logLimits) + .build() + } + + object HttpRecordBatchExporter { + @RequiresApi(Build.VERSION_CODES.O) + fun create(extraHttpHeaders: Map) = + LogRecordExporterConfig.otlpHttpLogRecordExporter( + extraHttpHeaders, + "${BASE_URL}/v1/logs" + ) + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt new file mode 100644 index 000000000..a5b09ef14 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/config/OtelConfigShared.kt @@ -0,0 +1,52 @@ +package com.onesignal.debug.internal.logging.otel.config + +import android.os.Build +import androidx.annotation.RequiresApi +import io.opentelemetry.sdk.logs.LogLimits +import io.opentelemetry.sdk.logs.LogRecordProcessor +import io.opentelemetry.sdk.logs.export.BatchLogRecordProcessor +import io.opentelemetry.sdk.logs.export.LogRecordExporter +import io.opentelemetry.sdk.resources.Resource +import io.opentelemetry.sdk.resources.ResourceBuilder +import io.opentelemetry.semconv.ServiceAttributes +import java.time.Duration + +internal fun ResourceBuilder.putAll(attributes: Map): ResourceBuilder { + attributes.forEach { this.put(it.key, it.value) } + return this +} + +internal class OtelConfigShared { + object ResourceConfig { + fun create(attributes: Map): Resource = + Resource + .getDefault() + .toBuilder() + .put(ServiceAttributes.SERVICE_NAME, "OneSignalDeviceSDK") + .putAll(attributes) + .build() + } + + object LogRecordProcessorConfig { + @RequiresApi(Build.VERSION_CODES.O) + fun batchLogRecordProcessor(logRecordExporter: LogRecordExporter): LogRecordProcessor = + BatchLogRecordProcessor + .builder(logRecordExporter) + .setMaxQueueSize(100) + .setMaxExportBatchSize(100) + .setExporterTimeout(Duration.ofSeconds(30)) + .setScheduleDelay(Duration.ofSeconds(1)) + .build() + } + + object LogLimitsConfig { + fun logLimits(): LogLimits = + LogLimits + .builder() + .setMaxNumberOfAttributes(128) + // We want a high value max length as the exception.stacktrace + // value can be lengthly. + .setMaxAttributeValueLength(32000) + .build() + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/IOneSignalCrashConfigProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/IOneSignalCrashConfigProvider.kt new file mode 100644 index 000000000..37b9741a7 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/IOneSignalCrashConfigProvider.kt @@ -0,0 +1,7 @@ +package com.onesignal.debug.internal.logging.otel.crash + +internal interface IOneSignalCrashConfigProvider { + val path: String + + val minFileAgeForReadMillis: Long +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashConfigProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashConfigProvider.kt new file mode 100644 index 000000000..4e40dccaf --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashConfigProvider.kt @@ -0,0 +1,17 @@ +package com.onesignal.debug.internal.logging.otel.crash + +import com.onesignal.core.internal.application.IApplicationService +import java.io.File + +internal class OneSignalCrashConfigProvider( + private val _applicationService: IApplicationService +) : IOneSignalCrashConfigProvider { + override val path: String by lazy { + _applicationService.appContext.cacheDir.path + File.separator + + "onesignal" + File.separator + + "otel" + File.separator + + "crashes" + } + + override val minFileAgeForReadMillis: Long = 5_000 +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt new file mode 100644 index 000000000..e711978ca --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashReporterOtel.kt @@ -0,0 +1,38 @@ +package com.onesignal.debug.internal.logging.otel.crash + +import com.onesignal.debug.internal.crash.IOneSignalCrashReporter +import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetryCrash +import com.onesignal.debug.internal.logging.otel.attributes.OS_OTEL_NAMESPACE +import io.opentelemetry.api.common.Attributes +import io.opentelemetry.api.logs.Severity + +internal class OneSignalCrashReporterOtel( + val _openTelemetry: IOneSignalOpenTelemetryCrash +) : IOneSignalCrashReporter { + companion object { + private const val OTEL_EXCEPTION_TYPE = "exception.type" + private const val OTEL_EXCEPTION_MESSAGE = "exception.message" + private const val OTEL_EXCEPTION_STACKTRACE = "exception.stacktrace" + } + + override suspend fun saveCrash(thread: Thread, throwable: Throwable) { + val attributesBuilder = + Attributes + .builder() + .put(OTEL_EXCEPTION_MESSAGE, throwable.message) + .put(OTEL_EXCEPTION_STACKTRACE, throwable.stackTraceToString()) + .put(OTEL_EXCEPTION_TYPE, throwable.javaClass.name) + // This matches the top level thread.name today, but it may not + // always if things are refactored to use a different thread. + .put("$OS_OTEL_NAMESPACE.exception.thread.name", thread.name) + .build() + + _openTelemetry + .getLogger() + .setAllAttributes(attributesBuilder) + .setSeverity(Severity.FATAL) + .emit() + + _openTelemetry.forceFlush() + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt new file mode 100644 index 000000000..6346b260c --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt @@ -0,0 +1,60 @@ +package com.onesignal.debug.internal.logging.otel.crash + +import com.onesignal.core.internal.startup.IStartableService +import com.onesignal.debug.internal.logging.otel.IOneSignalOpenTelemetryRemote +import com.onesignal.debug.internal.logging.otel.config.OtelConfigCrashFile +import io.opentelemetry.sdk.logs.data.LogRecordData +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import java.util.concurrent.TimeUnit + +/** + * Purpose: This reads a local crash report files created by OneSignal's + * crash handler and sends them to OneSignal on the app's next start. + */ +internal class OneSignalCrashUploader( + private val _openTelemetryRemote: IOneSignalOpenTelemetryRemote, + private val _crashPathProvider: IOneSignalCrashConfigProvider, +) : IStartableService { + companion object { + const val SEND_TIMEOUT_SECONDS = 30L + } + + private fun getReports() = + OtelConfigCrashFile.SdkLoggerProviderConfig + .getFileLogRecordStorage( + _crashPathProvider.path, + _crashPathProvider.minFileAgeForReadMillis + ).iterator() + + override fun start() { + runBlocking { internalStart() } + } + + /** + * NOTE: sendCrashReports is called twice for the these reasons: + * 1. We want to send crash reports as soon as possible. + * - App may crash quickly after starting a 2nd time. + * 2. Reports could be delayed until the 2nd start after a crash + * - Otel doesn't let you read a file it could be writing so we must + * wait a minium amount of time after a crash to ensure we get the + * report from the last crash. + */ + suspend fun internalStart() { + sendCrashReports(getReports()) + delay(_crashPathProvider.minFileAgeForReadMillis) + sendCrashReports(getReports()) + } + + private fun sendCrashReports(reports: Iterator>) { + val networkExporter = _openTelemetryRemote.logExporter + var failed = false + // NOTE: next() will delete the previous report, so we only want to send + // another one if there isn't an issue making network calls. + while (reports.hasNext() && !failed) { + val future = networkExporter.export(reports.next()) + val result = future.join(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS) + failed = !result.isSuccess + } + } +} diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt index 1f91f2b4c..78d9c4718 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/restoration/impl/NotificationRestoreWorkManager.kt @@ -17,13 +17,14 @@ internal class NotificationRestoreWorkManager : INotificationRestoreWorkManager // Notifications will never be force removed when the app's process is running, // so we only need to restore at most once per cold start of the app. private var restored = false + private val lock = Any() override fun beginEnqueueingWork( context: Context, shouldDelay: Boolean, ) { // Only allow one piece of work to be enqueued. - synchronized(restored) { + synchronized(lock) { if (restored) { return }