diff --git a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt index eebf9469c0..4aa9767006 100644 --- a/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt +++ b/OneSignalSDK/onesignal/core/src/main/java/com/onesignal/debug/internal/logging/otel/android/OtelPlatformProvider.kt @@ -9,6 +9,9 @@ import com.onesignal.core.internal.http.OneSignalService import com.onesignal.debug.internal.logging.Logging import com.onesignal.otel.IOtelPlatformProvider +// Use this to enable/disable the Otel exporter logging in debug builds. +internal const val OTEL_EXPORTER_LOGGING_ENABLED = false + /** * Configuration for AndroidOtelPlatformProvider. */ @@ -135,6 +138,8 @@ internal class OtelPlatformProvider( } } + override val isOtelExporterLoggingEnabled: Boolean = OTEL_EXPORTER_LOGGING_ENABLED + override val appIdForHeaders: String get() = appId ?: "" diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/bundle/impl/NotificationBundleProcessor.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/bundle/impl/NotificationBundleProcessor.kt index 9e95d4115e..138f2bc0c7 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/bundle/impl/NotificationBundleProcessor.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/bundle/impl/NotificationBundleProcessor.kt @@ -7,6 +7,7 @@ import com.onesignal.core.internal.time.ITime import com.onesignal.notifications.internal.bundle.INotificationBundleProcessor import com.onesignal.notifications.internal.common.NotificationConstants import com.onesignal.notifications.internal.common.NotificationFormatHelper +import com.onesignal.notifications.internal.common.NotificationPriorityMapper import com.onesignal.notifications.internal.generation.INotificationGenerationWorkManager import org.json.JSONArray import org.json.JSONException @@ -84,7 +85,7 @@ internal class NotificationBundleProcessor( val jsonPayload = JSONUtils.bundleAsJSONObject(bundle) val timestamp = _time.currentTimeMillis / 1000L val isRestoring = bundle.getBoolean("is_restoring", false) - val isHighPriority = bundle.getString("pri", "0").toInt() > 9 + val isHighPriority = NotificationPriorityMapper.isHighPriority(bundle.getString("pri", "0").toInt()) val osNotificationId = NotificationFormatHelper.getOSNotificationIdFromJson(jsonPayload) var androidNotificationId = 0 diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/channels/impl/NotificationChannelManager.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/channels/impl/NotificationChannelManager.kt index 45577fc7c9..f6ec933cd1 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/channels/impl/NotificationChannelManager.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/channels/impl/NotificationChannelManager.kt @@ -7,13 +7,13 @@ import android.app.NotificationManager import android.content.Context import android.os.Build import androidx.annotation.RequiresApi -import androidx.core.app.NotificationManagerCompat import com.onesignal.core.internal.application.IApplicationService import com.onesignal.core.internal.language.ILanguageContext import com.onesignal.debug.internal.logging.Logging import com.onesignal.notifications.internal.channels.INotificationChannelManager import com.onesignal.notifications.internal.common.NotificationGenerationJob import com.onesignal.notifications.internal.common.NotificationHelper +import com.onesignal.notifications.internal.common.NotificationPriorityMapper import org.json.JSONArray import org.json.JSONException import org.json.JSONObject @@ -226,11 +226,5 @@ internal class NotificationChannelManager( } } - private fun priorityToImportance(priority: Int): Int { - if (priority > 9) return NotificationManagerCompat.IMPORTANCE_MAX - if (priority > 7) return NotificationManagerCompat.IMPORTANCE_HIGH - if (priority > 5) return NotificationManagerCompat.IMPORTANCE_DEFAULT - if (priority > 3) return NotificationManagerCompat.IMPORTANCE_LOW - return if (priority > 1) NotificationManagerCompat.IMPORTANCE_MIN else NotificationManagerCompat.IMPORTANCE_NONE - } + private fun priorityToImportance(priority: Int): Int = NotificationPriorityMapper.toAndroidImportance(priority) } diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/common/NotificationPriorityMapper.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/common/NotificationPriorityMapper.kt new file mode 100644 index 0000000000..7c73871150 --- /dev/null +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/common/NotificationPriorityMapper.kt @@ -0,0 +1,37 @@ +package com.onesignal.notifications.internal.common + +import androidx.core.app.NotificationCompat +import androidx.core.app.NotificationManagerCompat + +/** + * Single source of truth for mapping OneSignal payload priority (1–10) to + * Android notification priority and channel importance levels. + * + * Threshold table (OneSignal pri → Android level): + * 9–10 → MAX + * 7–8 → HIGH + * 5–6 → DEFAULT + * 3–4 → LOW + * 1–2 → MIN + * 0 → NONE (importance only) + */ +internal object NotificationPriorityMapper { + private const val HIGH_PRIORITY_THRESHOLD = 9 + + fun isHighPriority(osPriority: Int): Boolean = osPriority >= HIGH_PRIORITY_THRESHOLD + + fun toAndroidPriority(osPriority: Int): Int { + if (osPriority >= HIGH_PRIORITY_THRESHOLD) return NotificationCompat.PRIORITY_MAX + if (osPriority >= 7) return NotificationCompat.PRIORITY_HIGH + if (osPriority >= 5) return NotificationCompat.PRIORITY_DEFAULT + return if (osPriority >= 3) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN + } + + fun toAndroidImportance(osPriority: Int): Int { + if (osPriority >= HIGH_PRIORITY_THRESHOLD) return NotificationManagerCompat.IMPORTANCE_MAX + if (osPriority >= 7) return NotificationManagerCompat.IMPORTANCE_HIGH + if (osPriority >= 5) return NotificationManagerCompat.IMPORTANCE_DEFAULT + if (osPriority >= 3) return NotificationManagerCompat.IMPORTANCE_LOW + return if (osPriority >= 1) NotificationManagerCompat.IMPORTANCE_MIN else NotificationManagerCompat.IMPORTANCE_NONE + } +} diff --git a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/display/impl/NotificationDisplayBuilder.kt b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/display/impl/NotificationDisplayBuilder.kt index 6bcb1e8af4..f72f4e98f3 100644 --- a/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/display/impl/NotificationDisplayBuilder.kt +++ b/OneSignalSDK/onesignal/notifications/src/main/java/com/onesignal/notifications/internal/display/impl/NotificationDisplayBuilder.kt @@ -19,6 +19,7 @@ import com.onesignal.notifications.internal.channels.INotificationChannelManager import com.onesignal.notifications.internal.common.NotificationConstants import com.onesignal.notifications.internal.common.NotificationGenerationJob import com.onesignal.notifications.internal.common.NotificationHelper +import com.onesignal.notifications.internal.common.NotificationPriorityMapper import com.onesignal.notifications.internal.display.INotificationDisplayBuilder import com.onesignal.notifications.receivers.NotificationDismissReceiver import org.json.JSONException @@ -455,12 +456,7 @@ internal class NotificationDisplayBuilder( } } - private fun convertOSToAndroidPriority(priority: Int): Int { - if (priority > 9) return NotificationCompat.PRIORITY_MAX - if (priority > 7) return NotificationCompat.PRIORITY_HIGH - if (priority > 4) return NotificationCompat.PRIORITY_DEFAULT - return if (priority > 2) NotificationCompat.PRIORITY_LOW else NotificationCompat.PRIORITY_MIN - } + private fun convertOSToAndroidPriority(priority: Int): Int = NotificationPriorityMapper.toAndroidPriority(priority) internal class OneSignalNotificationBuilder { var compatBuilder: NotificationCompat.Builder? = null diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/bundle/NotificationBundleProcessorTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/bundle/NotificationBundleProcessorTests.kt new file mode 100644 index 0000000000..bd696ae686 --- /dev/null +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/bundle/NotificationBundleProcessorTests.kt @@ -0,0 +1,72 @@ +package com.onesignal.notifications.internal.bundle + +import android.content.Context +import android.os.Bundle +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.mocks.MockHelper +import com.onesignal.notifications.internal.bundle.impl.NotificationBundleProcessor +import com.onesignal.notifications.internal.generation.INotificationGenerationWorkManager +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.mockk.every +import io.mockk.mockk +import io.mockk.slot +import org.json.JSONObject +import org.robolectric.annotation.Config + +@Config( + packageName = "com.onesignal.example", + sdk = [26], +) +@RobolectricTest +class NotificationBundleProcessorTests : FunSpec({ + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + fun buildOneSignalBundle(pri: String): Bundle { + val bundle = Bundle() + bundle.putString("custom", JSONObject().put("i", "test-notif-id").toString()) + bundle.putString("alert", "test message") + bundle.putString("pri", pri) + return bundle + } + + fun captureIsHighPriority(pri: String): Boolean { + val isHighPrioritySlot = slot() + val workManager = mockk() + every { + workManager.beginEnqueueingWork( + any(), any(), any(), any(), any(), any(), + capture(isHighPrioritySlot), + ) + } returns true + + val processor = NotificationBundleProcessor(workManager, MockHelper.time(1111)) + val context = mockk(relaxed = true) + processor.processBundleFromReceiver(context, buildOneSignalBundle(pri)) + + return isHighPrioritySlot.captured + } + + test("pri 10 should be treated as high priority") { + captureIsHighPriority("10") shouldBe true + } + + test("pri 9 should be treated as high priority") { + captureIsHighPriority("9") shouldBe true + } + + test("pri 8 should not be treated as high priority") { + captureIsHighPriority("8") shouldBe false + } + + // Regression: pri=9 was previously not treated as high priority due to strict > 9 check. + // The backend sends pri=9 for the highest dashboard priority setting, so it must be + // classified as high priority for correct work manager scheduling. + test("regression - pri 9 must be classified as high priority") { + captureIsHighPriority("9") shouldBe true + } +}) diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/channels/NotificationChannelManagerTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/channels/NotificationChannelManagerTests.kt index 6636094989..bf810444d8 100644 --- a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/channels/NotificationChannelManagerTests.kt +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/channels/NotificationChannelManagerTests.kt @@ -243,6 +243,73 @@ class NotificationChannelManagerTests : FunSpec({ getChannel("OS_id1", ApplicationProvider.getApplicationContext()) shouldNotBe null } + fun createChannelWithPri(pri: Int): Int { + val mockTime = MockHelper.time(1111) + val notificationChannelManager = NotificationChannelManager(AndroidMockHelper.applicationService(), MockHelper.languageContext()) + val channelId = "test_pri_$pri" + val payload = + JSONObject() + .put("pri", pri) + .put( + "chnl", + JSONObject() + .put("id", channelId), + ) + notificationChannelManager.createNotificationChannel(NotificationGenerationJob(payload, mockTime)) + val notificationManager = + ApplicationProvider.getApplicationContext() + .getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + return notificationManager.getNotificationChannel(channelId)!!.importance + } + + test("createNotificationChannel with pri 10 should have IMPORTANCE_MAX") { + createChannelWithPri(10) shouldBe NotificationManager.IMPORTANCE_MAX + } + + test("createNotificationChannel with pri 9 should have IMPORTANCE_MAX") { + createChannelWithPri(9) shouldBe NotificationManager.IMPORTANCE_MAX + } + + test("createNotificationChannel with pri 8 should have IMPORTANCE_HIGH") { + createChannelWithPri(8) shouldBe NotificationManager.IMPORTANCE_HIGH + } + + test("createNotificationChannel with pri 7 should have IMPORTANCE_HIGH") { + createChannelWithPri(7) shouldBe NotificationManager.IMPORTANCE_HIGH + } + + test("createNotificationChannel with pri 6 should have IMPORTANCE_DEFAULT") { + createChannelWithPri(6) shouldBe NotificationManager.IMPORTANCE_DEFAULT + } + + test("createNotificationChannel with pri 5 should have IMPORTANCE_DEFAULT") { + createChannelWithPri(5) shouldBe NotificationManager.IMPORTANCE_DEFAULT + } + + test("createNotificationChannel with pri 4 should have IMPORTANCE_LOW") { + createChannelWithPri(4) shouldBe NotificationManager.IMPORTANCE_LOW + } + + test("createNotificationChannel with pri 3 should have IMPORTANCE_LOW") { + createChannelWithPri(3) shouldBe NotificationManager.IMPORTANCE_LOW + } + + test("createNotificationChannel with pri 2 should have IMPORTANCE_MIN") { + createChannelWithPri(2) shouldBe NotificationManager.IMPORTANCE_MIN + } + + test("createNotificationChannel with pri 1 should have IMPORTANCE_MIN") { + createChannelWithPri(1) shouldBe NotificationManager.IMPORTANCE_MIN + } + + // Regression: pri=9 previously mapped to IMPORTANCE_HIGH due to strict > 9 check. + // The backend sends pri=9 for the highest dashboard priority, so the channel must + // be created with IMPORTANCE_MAX. + test("regression - createNotificationChannel with pri 9 must not have IMPORTANCE_HIGH") { + createChannelWithPri(9) shouldBe NotificationManager.IMPORTANCE_MAX + createChannelWithPri(9) shouldNotBe NotificationManager.IMPORTANCE_HIGH + } + test("processChannelList multilanguage") { // Given val notificationChannelManager = NotificationChannelManager(AndroidMockHelper.applicationService(), MockHelper.languageContext()) diff --git a/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/display/NotificationDisplayBuilderTests.kt b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/display/NotificationDisplayBuilderTests.kt new file mode 100644 index 0000000000..bc129538e8 --- /dev/null +++ b/OneSignalSDK/onesignal/notifications/src/test/java/com/onesignal/notifications/internal/display/NotificationDisplayBuilderTests.kt @@ -0,0 +1,102 @@ +package com.onesignal.notifications.internal.display + +import androidx.core.app.NotificationCompat +import br.com.colman.kotest.android.extensions.robolectric.RobolectricTest +import com.onesignal.debug.LogLevel +import com.onesignal.debug.internal.logging.Logging +import com.onesignal.mocks.AndroidMockHelper +import com.onesignal.mocks.MockHelper +import com.onesignal.notifications.internal.channels.INotificationChannelManager +import com.onesignal.notifications.internal.common.NotificationGenerationJob +import com.onesignal.notifications.internal.display.impl.NotificationDisplayBuilder +import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.shouldBe +import io.kotest.matchers.shouldNotBe +import io.mockk.every +import io.mockk.mockk +import org.json.JSONObject +import org.robolectric.annotation.Config + +@Config( + packageName = "com.onesignal.example", + sdk = [26], +) +@RobolectricTest +class NotificationDisplayBuilderTests : FunSpec({ + beforeAny { + Logging.logLevel = LogLevel.NONE + } + + fun buildNotificationPriority(pri: Int?): Int { + val channelManager = mockk() + every { channelManager.createNotificationChannel(any()) } returns "test_channel" + + val builder = NotificationDisplayBuilder( + AndroidMockHelper.applicationService(), + channelManager, + ) + + val payload = JSONObject() + .put("alert", "test") + .put("custom", JSONObject().put("i", "test-id")) + if (pri != null) { + payload.put("pri", pri) + } + + val job = NotificationGenerationJob(payload, MockHelper.time(1111)) + val result = builder.getBaseOneSignalNotificationBuilder(job) + return result.compatBuilder!!.build().priority + } + + test("pri 10 should map to PRIORITY_MAX") { + buildNotificationPriority(10) shouldBe NotificationCompat.PRIORITY_MAX + } + + test("pri 9 should map to PRIORITY_MAX") { + buildNotificationPriority(9) shouldBe NotificationCompat.PRIORITY_MAX + } + + test("pri 8 should map to PRIORITY_HIGH") { + buildNotificationPriority(8) shouldBe NotificationCompat.PRIORITY_HIGH + } + + test("pri 7 should map to PRIORITY_HIGH") { + buildNotificationPriority(7) shouldBe NotificationCompat.PRIORITY_HIGH + } + + test("pri 6 should map to PRIORITY_DEFAULT") { + buildNotificationPriority(6) shouldBe NotificationCompat.PRIORITY_DEFAULT + } + + test("pri 5 should map to PRIORITY_DEFAULT") { + buildNotificationPriority(5) shouldBe NotificationCompat.PRIORITY_DEFAULT + } + + test("pri 4 should map to PRIORITY_LOW") { + buildNotificationPriority(4) shouldBe NotificationCompat.PRIORITY_LOW + } + + test("pri 3 should map to PRIORITY_LOW") { + buildNotificationPriority(3) shouldBe NotificationCompat.PRIORITY_LOW + } + + test("pri 2 should map to PRIORITY_MIN") { + buildNotificationPriority(2) shouldBe NotificationCompat.PRIORITY_MIN + } + + test("pri 1 should map to PRIORITY_MIN") { + buildNotificationPriority(1) shouldBe NotificationCompat.PRIORITY_MIN + } + + test("missing pri should default to PRIORITY_DEFAULT") { + buildNotificationPriority(null) shouldBe NotificationCompat.PRIORITY_DEFAULT + } + + // Regression: pri=9 previously mapped to PRIORITY_HIGH due to strict > 9 check. + // The backend sends pri=9 for the highest dashboard priority, so this must yield + // PRIORITY_MAX to match competitor notification ranking behavior. + test("regression - pri 9 must not map to PRIORITY_HIGH") { + buildNotificationPriority(9) shouldBe NotificationCompat.PRIORITY_MAX + buildNotificationPriority(9) shouldNotBe NotificationCompat.PRIORITY_HIGH + } +}) diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt index 98978ee19b..f13549bc04 100644 --- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/IOtelPlatformProvider.kt @@ -53,6 +53,13 @@ interface IOtelPlatformProvider { * Valid values: "NONE", "FATAL", "ERROR", "WARN", "INFO", "DEBUG", "VERBOSE" */ val remoteLogLevel: String? + + /** + * Debug-only toggle for local exporter diagnostics. + * When true, Otel exporter request/response success/failure logs are emitted to logcat. + */ + val isOtelExporterLoggingEnabled: Boolean + val appIdForHeaders: String /** diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt index 3e470f9422..ea66980ab3 100644 --- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/OneSignalOpenTelemetry.kt @@ -17,24 +17,6 @@ internal fun LogRecordBuilder.setAllAttributes(attributes: Map): return this } -/** - * Extension function to set all attributes from an Attributes object. - * Made public so it can be used from other modules (e.g., core module for logging). - */ -fun LogRecordBuilder.setAllAttributes(attributes: io.opentelemetry.api.common.Attributes): LogRecordBuilder { - attributes.forEach { key, value -> - val keyString = key.key - when (value) { - is String -> this.setAttribute(keyString, value) - is Long -> this.setAttribute(keyString, value) - is Double -> this.setAttribute(keyString, value) - is Boolean -> this.setAttribute(keyString, value) - else -> this.setAttribute(keyString, value.toString()) - } - } - return this -} - internal abstract class OneSignalOpenTelemetryBase( private val osTopLevelFields: OtelFieldsTopLevel, private val osPerEventFields: OtelFieldsPerEvent, @@ -103,15 +85,19 @@ internal class OneSignalOpenTelemetryRemote( val extraHttpHeaders: Map by lazy { mapOf( - "X-OneSignal-App-Id" to appId, - "X-OneSignal-SDK-Version" to platformProvider.sdkBaseVersion, + "SDK-Version" to "onesignal/${platformProvider.sdkBase}/${platformProvider.sdkBaseVersion}", ) } private val apiBaseUrl: String get() = platformProvider.apiBaseUrl override val logExporter by lazy { - OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(extraHttpHeaders, appId, apiBaseUrl) + OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create( + extraHttpHeaders, + appId, + apiBaseUrl, + platformProvider.isOtelExporterLoggingEnabled, + ) } override fun getSdkInstance(attributes: Map): OpenTelemetrySdk = @@ -123,6 +109,7 @@ internal class OneSignalOpenTelemetryRemote( extraHttpHeaders, appId, apiBaseUrl, + platformProvider.isOtelExporterLoggingEnabled, ) ).build() } diff --git a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt index b61cc268d4..f5bab709fb 100644 --- a/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt +++ b/OneSignalSDK/onesignal/otel/src/main/java/com/onesignal/otel/config/OtelConfigRemoteOneSignal.kt @@ -1,7 +1,10 @@ package com.onesignal.otel.config +import android.util.Log import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter +import io.opentelemetry.sdk.common.CompletableResultCode import io.opentelemetry.sdk.logs.SdkLoggerProvider +import io.opentelemetry.sdk.logs.data.LogRecordData import io.opentelemetry.sdk.logs.export.LogRecordExporter import java.time.Duration @@ -35,23 +38,97 @@ internal class OtelConfigRemoteOneSignal { extraHttpHeaders: Map, appId: String, apiBaseUrl: String, + enableExporterLogging: Boolean ): SdkLoggerProvider = SdkLoggerProvider .builder() .setResource(resource) .addLogRecordProcessor( OtelConfigShared.LogRecordProcessorConfig.batchLogRecordProcessor( - HttpRecordBatchExporter.create(extraHttpHeaders, appId, apiBaseUrl) + HttpRecordBatchExporter.create( + extraHttpHeaders, + appId, + apiBaseUrl, + enableExporterLogging, + ) ) ).setLogLimits(OtelConfigShared.LogLimitsConfig::logLimits) .build() } object HttpRecordBatchExporter { - fun create(extraHttpHeaders: Map, appId: String, apiBaseUrl: String) = - LogRecordExporterConfig.otlpHttpLogRecordExporter( - extraHttpHeaders, - buildEndpoint(apiBaseUrl, appId) - ) + fun create( + extraHttpHeaders: Map, + appId: String, + apiBaseUrl: String, + enableExporterLogging: Boolean, + ): LogRecordExporter { + val exporter = + LogRecordExporterConfig.otlpHttpLogRecordExporter( + extraHttpHeaders, + buildEndpoint(apiBaseUrl, appId) + ) + + return if (enableExporterLogging) { + ExporterLoggingConfig.loggingExporter(exporter) + } else { + exporter + } + } + } + + object ExporterLoggingConfig { + private const val TAG = "OneSignalOtel" + + fun loggingExporter(delegate: LogRecordExporter): LogRecordExporter = LoggingLogRecordExporter(delegate) + + private class LoggingLogRecordExporter( + private val delegate: LogRecordExporter + ) : LogRecordExporter { + @Suppress("TooGenericExceptionCaught") + private fun resolveHttpFailureMessage(throwable: Throwable?): String { + if (throwable == null) return "unknown" + + return try { + if (!throwable.javaClass.name.endsWith("FailedExportException\$HttpExportException")) { + return throwable.message ?: "unknown" + } + + val response = throwable.javaClass.getMethod("getResponse").invoke(throwable) ?: return throwable.message ?: "unknown" + val statusCode = response.javaClass.getMethod("statusCode").invoke(response) + val statusMessage = response.javaClass.getMethod("statusMessage").invoke(response) + val responseBodyBytes = response.javaClass.getMethod("responseBody").invoke(response) as? ByteArray + val responseBody = responseBodyBytes?.decodeToString() + + "status=$statusCode message=$statusMessage" + + (if (responseBody.isNullOrBlank()) "" else " body=$responseBody") + } catch (_: Throwable) { + throwable.message ?: "unknown" + } + } + + override fun export(logs: Collection): CompletableResultCode { + Log.d(TAG, "OTEL export request sent to backend. count=${logs.size}") + val result = delegate.export(logs) + result.whenComplete { + if (result.isSuccess) { + Log.d(TAG, "OTEL export response received: success") + } else { + val throwable = result.failureThrowable + val failureMessage = resolveHttpFailureMessage(throwable) + Log.e( + TAG, + "OTEL export response received: failed - $failureMessage", + throwable + ) + } + } + return result + } + + override fun flush(): CompletableResultCode = delegate.flush() + + override fun shutdown(): CompletableResultCode = delegate.shutdown() + } } } diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OneSignalOpenTelemetryTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OneSignalOpenTelemetryTest.kt index 5bc57abf30..775c1ad047 100644 --- a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OneSignalOpenTelemetryTest.kt +++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/OneSignalOpenTelemetryTest.kt @@ -1,13 +1,15 @@ package com.onesignal.otel import io.kotest.core.spec.style.FunSpec +import io.kotest.matchers.maps.shouldContainKey +import io.kotest.matchers.maps.shouldNotContainKey +import io.kotest.matchers.shouldBe import io.kotest.matchers.shouldNotBe import io.kotest.matchers.types.shouldBeInstanceOf import io.mockk.clearMocks import io.mockk.coEvery import io.mockk.every import io.mockk.mockk -import io.opentelemetry.api.common.Attributes import io.opentelemetry.api.logs.LogRecordBuilder import kotlinx.coroutines.runBlocking @@ -77,6 +79,15 @@ class OneSignalOpenTelemetryTest : FunSpec({ } } + test("remote telemetry should only send SDK-Version header and not legacy OneSignal SDK header") { + val remoteTelemetry = OtelFactory.createRemoteTelemetry(mockPlatformProvider) as OneSignalOpenTelemetryRemote + val headers = remoteTelemetry.extraHttpHeaders + + headers.shouldContainKey("SDK-Version") + headers["SDK-Version"] shouldBe "onesignal/android/5.0.0" + headers.shouldNotContainKey("X-OneSignal-SDK-Version") + } + // ===== Crash Local Telemetry Tests ===== test("createCrashLocalTelemetry should return IOtelOpenTelemetryCrash") { @@ -126,23 +137,6 @@ class OneSignalOpenTelemetryTest : FunSpec({ io.mockk.verify { mockBuilder.setAttribute("key2", "value2") } } - test("setAllAttributes with Attributes should handle different types") { - val mockBuilder = mockk(relaxed = true) - val attributes = Attributes.builder() - .put("string.key", "string-value") - .put("long.key", 123L) - .put("double.key", 45.67) - .put("boolean.key", true) - .build() - - mockBuilder.setAllAttributes(attributes) - - io.mockk.verify { mockBuilder.setAttribute("string.key", "string-value") } - io.mockk.verify { mockBuilder.setAttribute("long.key", 123L) } - io.mockk.verify { mockBuilder.setAttribute("double.key", 45.67) } - io.mockk.verify { mockBuilder.setAttribute("boolean.key", true) } - } - // ===== SDK Caching Tests ===== test("remote telemetry should cache SDK instance") { diff --git a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/config/OtelConfigTest.kt b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/config/OtelConfigTest.kt index f4f8daaf18..e31fdfea96 100644 --- a/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/config/OtelConfigTest.kt +++ b/OneSignalSDK/onesignal/otel/src/test/java/com/onesignal/otel/config/OtelConfigTest.kt @@ -59,7 +59,7 @@ class OtelConfigTest : FunSpec({ test("buildEndpoint should construct correct URL from base and appId") { val endpoint = OtelConfigRemoteOneSignal.buildEndpoint("https://api.onesignal.com", "my-app") - endpoint shouldBe "https://api.onesignal.com/sdk/otel/v1/logs?app_id=my-app" + endpoint shouldBe "https://api.onesignal.com/sdk/log?app_id=my-app" } test("HttpRecordBatchExporter should create exporter with correct endpoint") { @@ -67,7 +67,7 @@ class OtelConfigTest : FunSpec({ val appId = "test-app-id" val apiBaseUrl = "https://api.onesignal.com" - val exporter = OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(headers, appId, apiBaseUrl) + val exporter = OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(headers, appId, apiBaseUrl, false) exporter shouldNotBe null } @@ -93,6 +93,7 @@ class OtelConfigTest : FunSpec({ headers, "test-app-id", "https://api.onesignal.com", + false, ) provider shouldNotBe null