Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -135,6 +138,8 @@ internal class OtelPlatformProvider(
}
}

override val isOtelExporterLoggingEnabled: Boolean = OTEL_EXPORTER_LOGGING_ENABLED
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

how does isOtelExporterLoggingEnabled get set to true?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we set it manually whenever we want to debug in our local test env.


override val appIdForHeaders: String
get() = appId ?: ""

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,24 +17,6 @@ internal fun LogRecordBuilder.setAllAttributes(attributes: Map<String, String>):
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,
Expand Down Expand Up @@ -103,15 +85,19 @@ internal class OneSignalOpenTelemetryRemote(

val extraHttpHeaders: Map<String, String> by lazy {
mapOf(
"X-OneSignal-App-Id" to appId,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

we don't need app_id in header anymore because it's already captured elsewhere right

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thats right

"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<String, String>): OpenTelemetrySdk =
Expand All @@ -123,6 +109,7 @@ internal class OneSignalOpenTelemetryRemote(
extraHttpHeaders,
appId,
apiBaseUrl,
platformProvider.isOtelExporterLoggingEnabled,
)
).build()
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,19 @@
package com.onesignal.otel.config

import android.util.Log
import io.opentelemetry.exporter.otlp.http.logs.OtlpHttpLogRecordExporter
import io.opentelemetry.sdk.common.CompletableResultCode
import io.opentelemetry.sdk.logs.SdkLoggerProvider
import io.opentelemetry.sdk.logs.data.LogRecordData
import io.opentelemetry.sdk.logs.export.LogRecordExporter
import java.time.Duration

internal class OtelConfigRemoteOneSignal {
companion object {
const val OTEL_PATH = "sdk/otel"
const val OTEL_PATH = "sdk"

fun buildEndpoint(apiBaseUrl: String, appId: String): String =
"$apiBaseUrl$OTEL_PATH/v1/logs?app_id=$appId"
"$apiBaseUrl$OTEL_PATH/log?app_id=$appId"
}

object LogRecordExporterConfig {
Expand All @@ -35,23 +38,97 @@ internal class OtelConfigRemoteOneSignal {
extraHttpHeaders: Map<String, String>,
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<String, String>, appId: String, apiBaseUrl: String) =
LogRecordExporterConfig.otlpHttpLogRecordExporter(
extraHttpHeaders,
buildEndpoint(apiBaseUrl, appId)
)
fun create(
extraHttpHeaders: Map<String, String>,
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<LogRecordData>): 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()
}
}
}
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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<LogRecordBuilder>(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") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,15 +59,15 @@ 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") {
val headers = mapOf("X-Test-Header" to "test-value")
val appId = "test-app-id"
val apiBaseUrl = "https://api.onesignal.com"

val exporter = OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(headers, appId, apiBaseUrl)
val exporter = OtelConfigRemoteOneSignal.HttpRecordBatchExporter.create(headers, appId, apiBaseUrl, false)

exporter shouldNotBe null
}
Expand All @@ -93,6 +93,7 @@ class OtelConfigTest : FunSpec({
headers,
"test-app-id",
"https://api.onesignal.com",
false,
)

provider shouldNotBe null
Expand Down
Loading