Skip to content
Open
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
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@

### 🛠️ Bug fixes

- Fix R8 failures caused by optional Error Prone annotations in minified applications.
([#1972](https://github.com/open-telemetry/opentelemetry-android/pull/1972))

- Preserve explicit session IDs on logs so recovered native crashes and session-end events remain
associated with the session they describe.
([#1939](https://github.com/open-telemetry/opentelemetry-android/pull/1939))
Expand Down
2 changes: 1 addition & 1 deletion build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ kover {
filters {
excludes {
androidGeneratedClasses()
classes("*.BuildConfig")
classes("*.BuildConfig", "io.opentelemetry.android.smoketestapp.*")
}
}
}
Expand Down
4 changes: 4 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,7 @@ coverage:
default:
target: auto
threshold: 1%

# This harness is exercised by Android instrumented tests, which Kover cannot collect.
ignore:
- "smoke-test-app/**/*"
3 changes: 2 additions & 1 deletion core/consumer-rules.pro
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
-dontwarn com.google.auto.value.AutoValue$CopyAnnotations
-dontwarn com.google.auto.value.AutoValue
-dontwarn com.google.auto.value.extension.memoized.Memoized
-dontwarn com.google.errorprone.annotations.**
-dontwarn io.grpc.Channel
-dontwarn io.grpc.MethodDescriptor$Builder
-dontwarn io.grpc.MethodDescriptor$Marshaller
Expand All @@ -10,4 +11,4 @@
-dontwarn io.grpc.stub.AbstractFutureStub
-dontwarn io.grpc.stub.AbstractStub$StubFactory
-dontwarn io.grpc.stub.AbstractStub
-dontwarn io.opentelemetry.sdk.autoconfigure.**
-dontwarn io.opentelemetry.sdk.autoconfigure.**
2 changes: 2 additions & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,8 @@ include(":services")
include(":session")
include(":semconv")
include(":opentelemetry-android-bom")
include(":smoke-test")
include(":smoke-test-app")
includeFromDir("instrumentation")

fun includeFromDir(
Expand Down
27 changes: 27 additions & 0 deletions smoke-test-app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
plugins {
id("otel.android-app-conventions")
}

android {
namespace = "io.opentelemetry.android.smoketestapp"

defaultConfig {
applicationId = "io.opentelemetry.android.smoketestapp"
}

buildTypes {
release {
isMinifyEnabled = true
isShrinkResources = true
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
signingConfig = signingConfigs.getByName("debug")
}
}
}

dependencies {
implementation(project(":android-agent"))
}
6 changes: 6 additions & 0 deletions smoke-test-app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# AndroidJUnitRunner lives in the test APK and references this class from the target APK.
-keep class androidx.tracing.Trace { *; }

# AGP treats dependencies shared by a minified target and its test APK as target-provided. Keep
# Kotlin's runtime available to AndroidJUnitRunner, which executes inside the target process.
-keep class kotlin.** { *; }
17 changes: 17 additions & 0 deletions smoke-test-app/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">

<uses-permission android:name="android.permission.INTERNET" />

<application
android:label="minified-smoke-test"
android:theme="@android:style/Theme.Material.Light.NoActionBar"
android:usesCleartextTraffic="true"
tools:ignore="MissingApplicationIcon">
<activity
android:name=".SmokeTestActivity"
android:exported="false" />
</application>

</manifest>
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.android.smoketestapp

import android.app.Activity
import android.os.Bundle
import io.opentelemetry.android.agent.OpenTelemetryRumInitializer

const val OTLP_ENDPOINT_EXTRA = "io.opentelemetry.android.smoketest.OTLP_ENDPOINT"
const val SMOKE_TEST_SCOPE_NAME = "smoke-test"
const val SMOKE_TEST_SPAN_NAME = "minified-app-smoke-test"

class SmokeTestActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)

val endpoint =
requireNotNull(intent.getStringExtra(OTLP_ENDPOINT_EXTRA)) {
"Missing OTLP endpoint"
}
val openTelemetryRum =
OpenTelemetryRumInitializer.initialize(application) {
httpExport {
baseUrl = endpoint
}
diskBuffering {
enabled(false)
}
disableLogging()
disableMetrics()
}

try {
openTelemetryRum.openTelemetry
.tracerProvider
.get(SMOKE_TEST_SCOPE_NAME)
.spanBuilder(SMOKE_TEST_SPAN_NAME)
.startSpan()
.end()
} finally {
// Shutdown starts the exporter flush; the instrumentation test waits for its request.
openTelemetryRum.shutdown()
}
}
}
58 changes: 58 additions & 0 deletions smoke-test/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
import org.jetbrains.kotlin.gradle.dsl.KotlinVersion

plugins {
id("com.android.test")
id("otel.spotless-conventions")
}

android {
namespace = "io.opentelemetry.android.smoketest"
compileSdk = (property("android.compileSdk") as String).toInt()

defaultConfig {
minSdk = (property("android.minSdk") as String).toInt()
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}

targetProjectPath = ":smoke-test-app"

buildTypes {
create("release") {
isMinifyEnabled = true
signingConfig = signingConfigs.getByName("debug")
testProguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro",
)
}
}

compileOptions {
sourceCompatibility(rootProject.extra["java_version"] as JavaVersion)
targetCompatibility(rootProject.extra["java_version"] as JavaVersion)
}

kotlin {
compilerOptions {
jvmTarget.set(rootProject.extra["jvm_target"] as JvmTarget)
apiVersion.set(rootProject.extra["kotlin_min_supported_version"] as KotlinVersion)
languageVersion.set(rootProject.extra["kotlin_min_supported_version"] as KotlinVersion)
}
}
}

androidComponents {
// The test must run against the target's minified release variant.
beforeVariants(selector().withBuildType("debug")) {
it.enable = false
}
}

dependencies {
implementation(libs.androidx.junit)
implementation(libs.androidx.test.core)
implementation(libs.androidx.test.rules)
implementation(libs.androidx.test.runner)
implementation(libs.opentelemetry.proto)
}
6 changes: 6 additions & 0 deletions smoke-test/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
# Kotlin runs from the optimized target APK because AndroidJUnitRunner executes in its process.
# Keep the runtime methods used only by the test APK.
-keep class kotlin.** { *; }

# The OTLP protobuf artifact includes optional gRPC stubs; this test only decodes HTTP payloads.
-dontwarn io.grpc.**
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
/*
* Copyright The OpenTelemetry Authors
* SPDX-License-Identifier: Apache-2.0
*/

package io.opentelemetry.android.smoketest

import android.app.Activity
import android.content.Intent
import androidx.test.core.app.ActivityScenario
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.opentelemetry.android.smoketestapp.OTLP_ENDPOINT_EXTRA
import io.opentelemetry.android.smoketestapp.SMOKE_TEST_SCOPE_NAME
import io.opentelemetry.android.smoketestapp.SMOKE_TEST_SPAN_NAME
import io.opentelemetry.proto.collector.trace.v1.ExportTraceServiceRequest
import io.opentelemetry.proto.common.v1.KeyValue
import io.opentelemetry.proto.trace.v1.ResourceSpans
import io.opentelemetry.proto.trace.v1.ScopeSpans
import io.opentelemetry.proto.trace.v1.Span
import org.junit.Test
import org.junit.runner.RunWith
import java.io.InputStream
import java.util.concurrent.TimeUnit
import java.util.zip.GZIPInputStream

@RunWith(AndroidJUnit4::class)
class MinifiedAppSmokeTest {
@Test
fun appLaunchesAndExportsTrace() {
OtlpHttpServer().use { server ->
val intent =
Intent()
.setClassName(TARGET_PACKAGE, TARGET_ACTIVITY)
.putExtra(OTLP_ENDPOINT_EXTRA, server.url)

ActivityScenario.launch<Activity>(intent).use {
awaitExpectedTrace(server)
}
}
}

private fun awaitExpectedTrace(server: OtlpHttpServer) {
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(EXPORT_TIMEOUT_SECONDS)
val receivedTargets = mutableListOf<String>()
val receivedSpanNames = mutableListOf<String>()

while (System.nanoTime() < deadline) {
val request =
server.takeRequest(deadline - System.nanoTime()) ?: break
receivedTargets.add("${request.method} ${request.target}")
if (request.target != TRACE_PATH) {
continue
}

val exportRequest = parseTraceRequest(request)
exportRequest.resourceSpansList.forEach { resourceSpans ->
resourceSpans.scopeSpansList.forEach { scopeSpans ->
scopeSpans.spansList.forEach { span ->
receivedSpanNames.add(span.name)
if (span.name == SMOKE_TEST_SPAN_NAME) {
assertExpectedTrace(request, resourceSpans, scopeSpans, span)
return
}
}
}
}
}

throw AssertionError(
"Timed out waiting for span '$SMOKE_TEST_SPAN_NAME'. " +
"Requests: $receivedTargets; spans: $receivedSpanNames",
)
}

private fun assertExpectedTrace(
Comment thread
aranhave marked this conversation as resolved.
request: CapturedHttpRequest,
resourceSpans: ResourceSpans,
scopeSpans: ScopeSpans,
span: Span,
) {
assertSmoke(request.method == "POST") { "Expected POST but received ${request.method}" }
assertSmoke(request.headers["content-type"]?.substringBefore(';') == PROTOBUF_CONTENT_TYPE) {
"Expected $PROTOBUF_CONTENT_TYPE but received ${request.headers["content-type"]}"
}
assertSmoke(request.headers["content-encoding"] == "gzip") {
"Expected gzip content encoding but received ${request.headers["content-encoding"]}"
}
assertSmoke(scopeSpans.scope.name == SMOKE_TEST_SCOPE_NAME) {
"Expected scope '$SMOKE_TEST_SCOPE_NAME' but received '${scopeSpans.scope.name}'"
}

val resourceAttributes = resourceSpans.resource.attributesList.stringValues()
EXPECTED_RESOURCE_ATTRIBUTES.forEach { (key, value) ->
assertSmoke(resourceAttributes[key] == value) {
"Expected $key=$value resource attribute: $resourceAttributes"
}
}
listOf("telemetry.sdk.version", "device.model.name", "device.model.identifier").forEach { key ->
assertSmoke(!resourceAttributes[key].isNullOrBlank()) {
"Missing $key resource attribute: $resourceAttributes"
}
}
val spanAttributes = span.attributesList.stringValues()
assertSmoke(!spanAttributes["session.id"].isNullOrBlank()) {
"Expected a non-empty session.id span attribute: $spanAttributes"
}
assertValidId("trace", span.traceId.toByteArray(), TRACE_ID_BYTES)
assertValidId("span", span.spanId.toByteArray(), SPAN_ID_BYTES)
}

private fun parseTraceRequest(request: CapturedHttpRequest): ExportTraceServiceRequest {
val inputStream: InputStream =
when (val encoding = request.headers["content-encoding"]) {
null, "identity" -> request.body.inputStream()
"gzip" -> GZIPInputStream(request.body.inputStream())
else -> throw AssertionError("Unsupported OTLP content encoding: $encoding")
}
return inputStream.use(ExportTraceServiceRequest::parseFrom)
}

private fun List<KeyValue>.stringValues(): Map<String, String> = associate { it.key to it.value.stringValue }

private fun assertValidId(
name: String,
value: ByteArray,
expectedSize: Int,
) {
assertSmoke(value.size == expectedSize && value.any { it != 0.toByte() }) {
"Expected a valid $name ID but received ${value.joinToString("") { "%02x".format(it) }}"
}
}

private fun assertSmoke(
condition: Boolean,
message: () -> String,
) {
if (!condition) {
throw AssertionError(message())
}
}

private companion object {
const val TARGET_PACKAGE = "io.opentelemetry.android.smoketestapp"
const val TARGET_ACTIVITY = "$TARGET_PACKAGE.SmokeTestActivity"
const val TRACE_PATH = "/v1/traces"
const val PROTOBUF_CONTENT_TYPE = "application/x-protobuf"
const val SERVICE_NAME = "minified-smoke-test"
const val EXPORT_TIMEOUT_SECONDS = 15L
const val TRACE_ID_BYTES = 16
const val SPAN_ID_BYTES = 8
val EXPECTED_RESOURCE_ATTRIBUTES =
mapOf(
"service.name" to SERVICE_NAME,
"telemetry.sdk.name" to "opentelemetry",
"telemetry.sdk.language" to "java",
"os.type" to "linux",
)
}
}
Loading