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 123e747499..f5f78029bc 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 41a491f000..95eb107dd1 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 b0f2580711..a581fa085e 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 9083cddade..1c3da1fa48 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/backend/IParamsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/IParamsBackendService.kt index 514cc798bc..216810b302 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/IParamsBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/IParamsBackendService.kt @@ -36,6 +36,7 @@ class ParamsObject( var opRepoExecutionInterval: Long? = null, var influenceParams: InfluenceParamsObject, var fcmParams: FCMParamsObject, + val remoteLoggingParams: RemoteLoggingParamsObject, ) class InfluenceParamsObject( @@ -53,3 +54,7 @@ class FCMParamsObject( val appId: String? = null, val apiKey: String? = null, ) + +class RemoteLoggingParamsObject( + val enable: Boolean? = null, +) diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt index 85dd452d41..be7dbc4e44 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/backend/impl/ParamsBackendService.kt @@ -11,6 +11,7 @@ import com.onesignal.core.internal.backend.FCMParamsObject import com.onesignal.core.internal.backend.IParamsBackendService import com.onesignal.core.internal.backend.InfluenceParamsObject import com.onesignal.core.internal.backend.ParamsObject +import com.onesignal.core.internal.backend.RemoteLoggingParamsObject import com.onesignal.core.internal.http.CacheKeys import com.onesignal.core.internal.http.IHttpClient import com.onesignal.core.internal.http.impl.OptionalHeaders @@ -57,6 +58,15 @@ internal class ParamsBackendService( ) } + // Process Remote Logging params + var remoteLoggingParams: RemoteLoggingParamsObject? = null + responseJson.expandJSONObject("remote_logging") { + remoteLoggingParams = + RemoteLoggingParamsObject( + enable = it.safeBool("enable"), + ) + } + return ParamsObject( googleProjectNumber = responseJson.safeString("android_sender_id"), enterprise = responseJson.safeBool("enterp"), @@ -75,6 +85,7 @@ internal class ParamsBackendService( opRepoExecutionInterval = responseJson.safeLong("oprepo_execution_interval"), influenceParams = influenceParams ?: InfluenceParamsObject(), fcmParams = fcmParams ?: FCMParamsObject(), + remoteLoggingParams = remoteLoggingParams ?: RemoteLoggingParamsObject(), ) } diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt index 74d31c4669..ff4423a06c 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/ConfigModel.kt @@ -301,6 +301,9 @@ class ConfigModel : Model() { val fcmParams: FCMConfigModel get() = getAnyProperty(::fcmParams.name) { FCMConfigModel(this, ::fcmParams.name) } as FCMConfigModel + val remoteLoggingParams: RemoteLoggingConfigModel + get() = getAnyProperty(::remoteLoggingParams.name) { RemoteLoggingConfigModel(this, ::remoteLoggingParams.name) } as RemoteLoggingConfigModel + override fun createModelForProperty( property: String, jsonObject: JSONObject, @@ -317,6 +320,12 @@ class ConfigModel : Model() { return model } + if (property == ::remoteLoggingParams.name) { + val model = RemoteLoggingConfigModel(this, ::remoteLoggingParams.name) + model.initializeFromJson(jsonObject) + return model + } + return null } } @@ -425,3 +434,17 @@ class FCMConfigModel(parentModel: Model, parentProperty: String) : Model(parentM setOptStringProperty(::apiKey.name, value) } } + +class RemoteLoggingConfigModel( + parentModel: Model, + parentProperty: String, +) : Model(parentModel, parentProperty) { + /** + * Do we send OneSignal related logs to OneSignal's server. + */ + var enable: Boolean? + get() = getOptBooleanProperty(::enable.name) { null } + set(value) { + setOptBooleanProperty(::enable.name, value) + } +} diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt index 5e3664e5f7..2f3c415619 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/core/internal/config/impl/ConfigModelStoreListener.kt @@ -103,6 +103,8 @@ internal class ConfigModelStoreListener( params.influenceParams.isIndirectEnabled?.let { config.influenceParams.isIndirectEnabled = it } params.influenceParams.isUnattributedEnabled?.let { config.influenceParams.isUnattributedEnabled = it } + params.remoteLoggingParams.enable?.let { config.remoteLoggingParams.enable = it } + _configModelStore.replace(config, ModelChangeTags.HYDRATE) success = true } catch (ex: BackendException) { 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 825637f31c..29b5578aec 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 ff35096efd..8f1824d481 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 231f37edf3..753ef124d5 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 0000000000..c51391c3d6 --- /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 0000000000..c2555dd78b --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/crash/OneSignalCrashHandler.kt @@ -0,0 +1,64 @@ +package com.onesignal.debug.internal.crash + +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, +) : Thread.UncaughtExceptionHandler { + private var existingHandler: Thread.UncaughtExceptionHandler? = null + private val seenThrowables: MutableList = mutableListOf() + + init { + 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 0000000000..2dd5590301 --- /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 0000000000..bff009784a --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/OneSignalOpenTelemetry.kt @@ -0,0 +1,124 @@ +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.Logging +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 { + private val appId: String get() = + try { + _configModelStore.model.appId + } catch (_: NullPointerException) { + Logging.error("Auth missing for crash log reporting!") + "" + } + + val extraHttpHeaders: Map by lazy { + mapOf( + "X-OneSignal-App-Id" to 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 0000000000..ede397474e --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsPerEvent.kt @@ -0,0 +1,94 @@ +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.debug.internal.logging.Logging +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["log.record.uid"] = recordId.toString() + + attributes + .putIfValueNotNull( + "$OS_OTEL_NAMESPACE.app_id", + appId + ).putIfValueNotNull( + "$OS_OTEL_NAMESPACE.onesignal_id", + onesignalId + ).putIfValueNotNull( + "$OS_OTEL_NAMESPACE.push_subscription_id", + subscriptionId + ) + + attributes["android.app.state"] = appState + attributes["process.uptime"] = processUptime.toString() + attributes["thread.name"] = currentThreadName + + return attributes.toUnmodifiableMap() + } + + private val appId: String? get() { + try { + return _configModelStore.model.appId + } catch (_: NullPointerException) { + Logging.warn("app_id not available to add to crash log") + return null + } + } + + private val onesignalId: String? get() { + try { + val onesignalId = _identityModelStore.model.onesignalId + if (IDManager.isLocalId(onesignalId)) { + return null + } + return onesignalId + } catch (_: NullPointerException) { + Logging.warn("onesignalId not available to add to crash log") + return null + } + } + + private val subscriptionId: String? get() { + try { + val pushSubscriptionId = _configModelStore.model.pushSubscriptionId + if (pushSubscriptionId == null || + IDManager.isLocalId(pushSubscriptionId)) { + return null + } + return pushSubscriptionId + } catch (_: NullPointerException) { + Logging.warn("subscriptionId not available to add to crash log") + return null + } + } + + // 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 0000000000..0ec1827df1 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/attributes/OneSignalOtelFieldsTopLevel.kt @@ -0,0 +1,67 @@ +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.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 _installIdService: IInstallIdService, +) { + suspend fun getAttributes(): Map { + val attributes: MutableMap = + mutableMapOf( + "$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 0000000000..0556d75a5b --- /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 0000000000..b6ffb30c53 --- /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 0000000000..a5b09ef149 --- /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 0000000000..37b9741a7c --- /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 0000000000..4e40dccaf1 --- /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 0000000000..e711978ca6 --- /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 0000000000..f39d593d93 --- /dev/null +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/crash/OneSignalCrashUploader.kt @@ -0,0 +1,72 @@ +package com.onesignal.debug.internal.logging.otel.crash + +import com.onesignal.core.internal.config.ConfigModelStore +import com.onesignal.core.internal.startup.IStartableService +import com.onesignal.debug.internal.logging.Logging +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, + _configModelStore: ConfigModelStore, +) : IStartableService { + companion object { + const val SEND_TIMEOUT_SECONDS = 30L + } + + private fun getReports() = + OtelConfigCrashFile.SdkLoggerProviderConfig + .getFileLogRecordStorage( + _crashPathProvider.path, + _crashPathProvider.minFileAgeForReadMillis + ).iterator() + + private val enable = + _configModelStore.model.remoteLoggingParams.enable ?: false + + override fun start() { + Logging.info("OneSignalCrashUploader.enable: $enable") + if (!enable) { + return + } + 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()) + Logging.debug("Sending OneSignal crash report") + val result = future.join(SEND_TIMEOUT_SECONDS, TimeUnit.SECONDS) + failed = !result.isSuccess + Logging.debug("Done OneSignal crash report, failed: $failed") + } + } +} 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 7b6ee4041e..b0c8c9cdfa 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 @@ -23,6 +23,7 @@ import com.onesignal.core.internal.startup.StartupService import com.onesignal.debug.IDebugManager import com.onesignal.debug.LogLevel import com.onesignal.debug.internal.DebugManager +import com.onesignal.debug.internal.crash.OneSignalCrashHandler import com.onesignal.debug.internal.logging.Logging import com.onesignal.inAppMessages.IInAppMessagesManager import com.onesignal.location.ILocationManager @@ -214,6 +215,11 @@ internal class OneSignalImp( // Give the logging singleton access to the application service to support visual logging. Logging.applicationService = applicationService + + // Crash handler needs to be one of the first things we setup, + // otherwise we'll not report some crashes, resulting in a false sense + // of stability. + services.getService() } private fun updateConfig() { 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 1f91f2b4c7..78d9c4718d 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 }