Skip to content

Conversation

@namanONcode
Copy link
Contributor

This pull request introduces a new Android instrumentation module for PANS (Per-Application Network Selection) within the OpenTelemetry project. The changes add all the core classes, Gradle configuration, permissions, and Proguard rules necessary to support the collection and reporting of per-app network metrics, network preference changes, and network availability on Android devices.

Key changes include:

Core functionality and data models

  • Added core data classes for PANS metrics, including PANSMetrics, AppNetworkUsage, PreferenceChange, and NetworkAvailability, along with helper functions to build OpenTelemetry attributes for these metrics (PANSMetrics.kt).
  • Introduced wrapper classes for Android's ConnectivityManager and NetworkStatsManager to safely and conveniently collect network state, availability, and per-app network usage statistics (ConnectivityManagerWrapper.kt, NetStatsManager.kt). [1] [2]

Module setup and configuration

  • Created a new Gradle build configuration for the PANS module, including dependencies, plugins, Jacoco coverage setup, and custom coverage reporting tasks (build.gradle.kts).
  • Added a Proguard consumer rules file to ensure all PANS instrumentation classes are kept and named correctly for consumers (consumer-rules.pro).
  • Defined the required Android permissions in the module's AndroidManifest.xml to allow network stats collection and monitoring (PACKAGE_USAGE_STATS, ACCESS_NETWORK_STATE, INTERNET).

API exposure

  • Generated the public API file for the new module, exposing the main classes and their methods for use by other components or consumers (pans.api).

Copilot AI review requested due to automatic review settings December 7, 2025 20:30
@namanONcode namanONcode requested a review from a team as a code owner December 7, 2025 20:30
Copy link

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

This pull request introduces a new Android instrumentation module for PANS (Per-Application Network Selection) that integrates with OpenTelemetry to collect per-app network metrics. The implementation provides the foundation for monitoring OEM network usage (OEM_PAID and OEM_PRIVATE network types) on Android devices, targeting system-level and automotive applications.

Key Changes

  • Core instrumentation framework: Added PansInstrumentation, PansMetricsCollector, and PANSMetricsExtractor classes to automatically collect and report network metrics via OpenTelemetry
  • Data models: Introduced data classes (AppNetworkUsage, PreferenceChange, NetworkAvailability) to represent PANS metrics with OpenTelemetry attribute builders
  • Android system wrappers: Created NetStatsManager and ConnectivityManagerWrapper to safely access network statistics and connectivity information with proper permission handling

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 10 comments.

Show a summary per file
File Description
PansInstrumentation.kt Main instrumentation entry point using AutoService for automatic registration
PansMetricsCollector.kt Orchestrates periodic metric collection and recording to OpenTelemetry
PANSMetricsExtractor.kt Extracts metrics from Android system services and tracks preference changes
PANSMetrics.kt Data models and attribute builder functions for PANS metrics
NetStatsManager.kt Wrapper for Android NetworkStatsManager with permission checks (currently placeholder)
ConnectivityManagerWrapper.kt Wrapper for ConnectivityManager to detect OEM network availability
AndroidManifest.xml Declares required permissions including PACKAGE_USAGE_STATS
build.gradle.kts Module build configuration with Jacoco coverage setup
consumer-rules.pro Proguard rules to preserve instrumentation classes
pans.api Public API definitions for the module
Test files (8 files) Comprehensive test suite covering all main components with Robolectric

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.


/**
* Gets all available networks with their capabilities.
*/
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

The suppression annotation @android.annotation.SuppressLint("WrongConstant") on line 46 suggests that constants CAP_OEM_PAID (19) and CAP_OEM_PRIVATE (20) might not be recognized as valid network capability constants by the linter.

While this may be intentional for forward compatibility, it should be documented why these constants are being used and on which API levels they're actually valid. The comment on line 117-120 provides some context but doesn't explain the SuppressLint annotation or potential compatibility issues.

Suggested change
*/
*/
// SuppressLint("WrongConstant") is used here because CAP_OEM_PAID (19) and CAP_OEM_PRIVATE (20)
// are not recognized as valid network capability constants by the linter. These constants are
// defined for forward compatibility with Android API 31+ (Android 12), where they represent
// OEM-managed network capabilities. On earlier API levels, these values may not be supported,
// but the code is protected by try/catch blocks and @RequiresApi(23). Developers should be
// aware that use of these constants may not be valid on all Android versions.

Copilot uses AI. Check for mistakes.
Comment on lines 115 to 322
Thread.sleep(50) // Let it initialize
collector.stop()
} catch (e: Exception) {
throw AssertionError("start() should not throw", e)
}
}

@Test
fun testStopDoesNotThrow() {
val collector = PansMetricsCollector(context, mockSdk)
try {
collector.stop()
} catch (e: Exception) {
throw AssertionError("stop() should not throw", e)
}
}

@Test
fun testStartThenStop() {
val collector = PansMetricsCollector(context, mockSdk)
collector.start()
Thread.sleep(50)
collector.stop()
}

@Test
fun testMultipleStarts() {
val collector = PansMetricsCollector(context, mockSdk)
collector.start()
collector.start() // Second start should be ignored
Thread.sleep(50)
collector.stop()
}

@Test
fun testMultipleStops() {
val collector = PansMetricsCollector(context, mockSdk)
collector.start()
Thread.sleep(50)
collector.stop()
collector.stop() // Second stop should be safe
collector.stop() // Third stop should be safe
}

@Test
fun testStopWithoutStart() {
val collector = PansMetricsCollector(context, mockSdk)
collector.stop() // Should not throw
}

@Test
fun testStartStopCycle() {
val collector = PansMetricsCollector(context, mockSdk)
collector.start()
Thread.sleep(50)
collector.stop()
}

// ==================== Error Handling Tests ====================

@Test
fun testCollectorCreationWithContext() {
try {
val collector = PansMetricsCollector(context, mockSdk)
assertNotNull(collector)
} catch (e: Exception) {
throw AssertionError("Collector creation should not throw", e)
}
}

@Test
fun testCollectorStartErrorHandling() {
val collector = PansMetricsCollector(context, mockSdk)
try {
collector.start()
Thread.sleep(50)
} catch (e: Exception) {
// Expected to handle errors gracefully
} finally {
collector.stop()
}
}

@Test
fun testCollectorWithRealContext() {
val realContext = ApplicationProvider.getApplicationContext<Context>()
val collector = PansMetricsCollector(realContext, mockSdk)
assertNotNull(collector)
}

@Test
fun testCollectorLifecycle() {
val collector = PansMetricsCollector(context, mockSdk)

// Create
assertNotNull(collector)

// Start
try {
collector.start()
Thread.sleep(50)
} catch (e: Exception) {
// May fail due to permissions, but should not crash
}

// Stop
collector.stop()
}

// ==================== Metrics Recording Tests ====================

@Test
fun testCollectorRecordsMetrics() {
val collector = PansMetricsCollector(context, mockSdk)
collector.start()
Thread.sleep(100) // Let it collect once
collector.stop()

// Verify meter was accessed
verify(atLeast = 1) { mockSdk.getMeter(any()) }
}

@Test
fun testCollectorCreatesBytesTransmittedCounter() {
val collector = PansMetricsCollector(context, mockSdk)
collector.start()
Thread.sleep(100)
collector.stop()

verify(atLeast = 1) { mockMeter.counterBuilder("network.pans.bytes_transmitted") }
}

@Test
fun testCollectorCreatesBytesReceivedCounter() {
val collector = PansMetricsCollector(context, mockSdk)
collector.start()
Thread.sleep(100)
collector.stop()

verify(atLeast = 1) { mockMeter.counterBuilder("network.pans.bytes_received") }
}

@Test
fun testCollectorCreatesNetworkAvailableGauge() {
val collector = PansMetricsCollector(context, mockSdk)
collector.start()
Thread.sleep(100)
collector.stop()

verify(atLeast = 1) { mockMeter.gaugeBuilder("network.pans.network_available") }
}

// ==================== Edge Cases ====================

@Test
fun testRapidStartStop() {
val collector = PansMetricsCollector(context, mockSdk)
repeat(3) {
try {
collector.start()
Thread.sleep(10)
collector.stop()
} catch (e: Exception) {
// Ignore errors during rapid start/stop
}
}
}

@Test
fun testCollectorStopsCleanly() {
val collector = PansMetricsCollector(context, mockSdk)
collector.start()
Thread.sleep(100)
collector.stop()
Thread.sleep(50) // Ensure cleanup
}

@Test
fun testManyCollectors() {
val collectors = mutableListOf<PansMetricsCollector>()
repeat(3) {
collectors.add(PansMetricsCollector(context, mockSdk))
}

collectors.forEach { it.start() }
Thread.sleep(50)
collectors.forEach { it.stop() }
}

@Test
fun testCollectorWithDifferentIntervals() {
val intervals = listOf(1L, 5L, 15L, 30L, 60L)
intervals.forEach { interval ->
val collector = PansMetricsCollector(context, mockSdk, collectionIntervalMinutes = interval)
assertNotNull(collector)
}
}

@Test
fun testCollectorApplicationContext() {
val appContext = context.applicationContext
val collector = PansMetricsCollector(appContext, mockSdk)
assertNotNull(collector)
collector.start()
Thread.sleep(50)
collector.stop()
}
}
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

The tests use Thread.sleep() to wait for asynchronous operations (lines 115, 136, 146, etc.), which makes tests slow and potentially flaky. Consider using countdown latches, test schedulers, or mocking the threading mechanism instead of using actual sleep statements.

For example, line 230 waits 100ms to "let it collect once", but this is an arbitrary time that may not be sufficient on slower test environments.

Copilot uses AI. Check for mistakes.
Comment on lines +5 to +6
<!-- Required to access network statistics data -->
<!-- This permission is intended for system/automotive apps -->
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

The comment states that PACKAGE_USAGE_STATS permission is "intended for system/automotive apps" (line 6), but the PR description doesn't specify that this instrumentation is only for system/automotive apps.

This is a protected permission that normal apps cannot obtain through runtime permission requests. This should be clearly documented in the module's README or main documentation that:

  1. This permission requires special privileges
  2. The instrumentation will have limited functionality on regular consumer apps
  3. It's primarily intended for system-level or automotive apps

This is important for setting correct expectations for users of this library.

Suggested change
<!-- Required to access network statistics data -->
<!-- This permission is intended for system/automotive apps -->
<!--
Required to access network statistics data.
NOTE: The PACKAGE_USAGE_STATS permission is a protected permission.
- Normal consumer apps cannot obtain this permission via runtime requests.
- This instrumentation will have limited functionality on regular consumer apps.
- It is primarily intended for system-level or automotive apps.
Please see the module's README for more details and requirements.
-->

Copilot uses AI. Check for mistakes.
Comment on lines +155 to +169
Thread {
while (isRunning.get()) {
try {
Thread.sleep(TimeUnit.MINUTES.toMillis(collectionIntervalMinutes))
if (isRunning.get()) {
collectMetrics()
}
} catch (e: InterruptedException) {
Thread.currentThread().interrupt()
break
} catch (e: Exception) {
Log.e(TAG, "Error in periodic collection", e)
}
}
}.start()
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

Creating a raw Thread for periodic scheduling is not recommended for production code. This approach:

  1. Has no way to properly cancel the thread (isRunning check is not sufficient)
  2. Blocks a thread unnecessarily
  3. Doesn't handle app lifecycle events
  4. May leak threads if stop() is not called

Consider using one of these alternatives:

  • Android's WorkManager for periodic background work
  • ScheduledExecutorService for better thread management
  • The existing PeriodicWork service mentioned in the comment

Copilot uses AI. Check for mistakes.
private val netStatsManager: NetStatsManager,
) {
private val connectivityManager = ConnectivityManagerWrapper(context)
private val preferencesCache = mutableMapOf<String, String>()
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

The preferencesCache field is declared but never actually used. It's populated in line 136 but the populated data is never read. Consider removing this field if it's not needed, or implement its intended functionality.

Suggested change
private val preferencesCache = mutableMapOf<String, String>()

Copilot uses AI. Check for mistakes.
Comment on lines +66 to +91
@Suppress("UnusedParameter")
private fun getNetworkStatsForType(
networkType: Int,
typeName: String,
): List<AppNetworkStats> {
val stats = mutableListOf<AppNetworkStats>()

if (statsManager == null) {
return stats
}

return try {
// For Android M-S (API 23-32), we use the available queryDetailsForUid API
// Note: Full OEM network type filtering requires API 34+
// The networkType parameter will be used when API 34+ support is added
Log.d(TAG, "Network stats for type $typeName not available on this API level")

stats
} catch (e: SecurityException) {
Log.w(TAG, "Security exception accessing network stats for type: $typeName", e)
stats
} catch (e: Exception) {
Log.e(TAG, "Error collecting stats for network type: $typeName", e)
stats
}
}
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

The @Suppress("UnusedParameter") annotation indicates that the networkType parameter is not being used, but the function implementation logs that it will be used "when API 34+ support is added" (line 80). This creates technical debt.

Consider either:

  1. Removing the parameter until API 34+ support is actually implemented
  2. Adding a TODO comment explaining exactly what needs to be done
  3. Implementing at least basic usage of the parameter if possible

The current approach makes the function signature misleading.

Copilot uses AI. Check for mistakes.
Comment on lines +67 to +91
private fun getNetworkStatsForType(
networkType: Int,
typeName: String,
): List<AppNetworkStats> {
val stats = mutableListOf<AppNetworkStats>()

if (statsManager == null) {
return stats
}

return try {
// For Android M-S (API 23-32), we use the available queryDetailsForUid API
// Note: Full OEM network type filtering requires API 34+
// The networkType parameter will be used when API 34+ support is added
Log.d(TAG, "Network stats for type $typeName not available on this API level")

stats
} catch (e: SecurityException) {
Log.w(TAG, "Security exception accessing network stats for type: $typeName", e)
stats
} catch (e: Exception) {
Log.e(TAG, "Error collecting stats for network type: $typeName", e)
stats
}
}
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

The function getNetworkStatsForType() always returns an empty list in the current implementation (line 83), making the calls to this function on lines 48 and 51 ineffective. This means getNetworkStats() will always return an empty list.

If this is intentional placeholder code for future API 34+ support, it should be clearly documented with a TODO or FIXME comment at the function level. Otherwise, this creates misleading behavior where the function appears to work but produces no results.

Copilot uses AI. Check for mistakes.
private val sdk: OpenTelemetrySdk,
private val collectionIntervalMinutes: Long = DEFAULT_COLLECTION_INTERVAL_MINUTES,
) {
private val logger: Meter = sdk.getMeter("io.opentelemetry.android.pans")
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

Variable naming issue: logger is declared as type Meter but is used for creating metrics, not logging. This should be renamed to meter for clarity and consistency with OpenTelemetry conventions.

Copilot uses AI. Check for mistakes.
Comment on lines +88 to +141
try {
// Record per-app network usage counters
val bytesTransmittedCounter =
logger
.counterBuilder("network.pans.bytes_transmitted")
.setUnit("By")
.setDescription("Bytes transmitted via OEM networks")
.build()

val bytesReceivedCounter =
logger
.counterBuilder("network.pans.bytes_received")
.setUnit("By")
.setDescription("Bytes received via OEM networks")
.build()

// Record app network preferences
metrics.appNetworkUsage.forEach { usage ->
bytesTransmittedCounter
.add(
usage.bytesTransmitted,
usage.attributes,
)
bytesReceivedCounter
.add(
usage.bytesReceived,
usage.attributes,
)
}

// Record network preference changes
metrics.preferenceChanges.forEach { change ->
try {
val eventLogger = sdk.logsBridge["io.opentelemetry.android.pans"]
eventLogger
.logRecordBuilder()
.setEventName("network.pans.preference_changed")
.setAllAttributes(change.attributes)
.emit()
} catch (e: Exception) {
Log.e(TAG, "Error recording preference change event", e)
}
}

// Record OEM network availability
logger
.gaugeBuilder("network.pans.network_available")
.setDescription("Whether OEM network is available")
.ofLongs()
.buildWithCallback { callback ->
metrics.networkAvailability.forEach { availability ->
callback.record(if (availability.isAvailable) 1L else 0L, availability.attributes)
}
}
Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

Metrics instruments (counters and gauges) are being created inside the recordMetrics() method, which gets called every collection cycle. This is inefficient and goes against OpenTelemetry best practices.

Metric instruments should be created once during initialization and reused across multiple recordings. Move the counter and gauge builder calls to the class initialization or constructor.

Copilot uses AI. Check for mistakes.
Comment on lines +154 to +191
if (networks.any { it.isOemPaid }) {
availability.add(
NetworkAvailability(
networkType = "OEM_PAID",
isAvailable = true,
attributes = buildNetworkAvailabilityAttributes("OEM_PAID"),
),
)
}

if (networks.any { it.isOemPrivate }) {
availability.add(
NetworkAvailability(
networkType = "OEM_PRIVATE",
isAvailable = true,
attributes = buildNetworkAvailabilityAttributes("OEM_PRIVATE"),
),
)
}

// If no OEM networks detected, still report them as unavailable
if (availability.isEmpty()) {
availability.add(
NetworkAvailability(
networkType = "OEM_PAID",
isAvailable = false,
attributes = buildNetworkAvailabilityAttributes("OEM_PAID"),
),
)
availability.add(
NetworkAvailability(
networkType = "OEM_PRIVATE",
isAvailable = false,
attributes = buildNetworkAvailabilityAttributes("OEM_PRIVATE"),
),
)
}

Copy link

Copilot AI Dec 7, 2025

Choose a reason for hiding this comment

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

The logic in lines 174-190 has a potential bug. When networks.any { it.isOemPaid } is true, an entry is added to availability. Then the check if (availability.isEmpty()) on line 175 will be false, so the "unavailable" entries won't be added for networks that weren't detected.

This means if only OEM_PAID is available, OEM_PRIVATE won't be reported as unavailable. The correct approach is to always report both network types (as available or unavailable) rather than conditionally reporting them.

Suggested change
if (networks.any { it.isOemPaid }) {
availability.add(
NetworkAvailability(
networkType = "OEM_PAID",
isAvailable = true,
attributes = buildNetworkAvailabilityAttributes("OEM_PAID"),
),
)
}
if (networks.any { it.isOemPrivate }) {
availability.add(
NetworkAvailability(
networkType = "OEM_PRIVATE",
isAvailable = true,
attributes = buildNetworkAvailabilityAttributes("OEM_PRIVATE"),
),
)
}
// If no OEM networks detected, still report them as unavailable
if (availability.isEmpty()) {
availability.add(
NetworkAvailability(
networkType = "OEM_PAID",
isAvailable = false,
attributes = buildNetworkAvailabilityAttributes("OEM_PAID"),
),
)
availability.add(
NetworkAvailability(
networkType = "OEM_PRIVATE",
isAvailable = false,
attributes = buildNetworkAvailabilityAttributes("OEM_PRIVATE"),
),
)
}
// Always report both OEM_PAID and OEM_PRIVATE, marking as available or unavailable
val oemPaidAvailable = networks.any { it.isOemPaid }
val oemPrivateAvailable = networks.any { it.isOemPrivate }
availability.add(
NetworkAvailability(
networkType = "OEM_PAID",
isAvailable = oemPaidAvailable,
attributes = buildNetworkAvailabilityAttributes("OEM_PAID"),
),
)
availability.add(
NetworkAvailability(
networkType = "OEM_PRIVATE",
isAvailable = oemPrivateAvailable,
attributes = buildNetworkAvailabilityAttributes("OEM_PRIVATE"),
),
)

Copilot uses AI. Check for mistakes.
@codecov
Copy link

codecov bot commented Dec 7, 2025

Codecov Report

❌ Patch coverage is 59.32203% with 120 lines in your changes missing coverage. Please review.
✅ Project coverage is 63.26%. Comparing base (fdebaab) to head (7bc073a).

Files with missing lines Patch % Lines
...droid/instrumentation/pans/PANSMetricsExtractor.kt 45.91% 50 Missing and 3 partials ⚠️
...ry/android/instrumentation/pans/NetStatsManager.kt 37.50% 21 Missing and 4 partials ⚠️
...droid/instrumentation/pans/PansMetricsCollector.kt 63.76% 24 Missing and 1 partial ⚠️
...instrumentation/pans/ConnectivityManagerWrapper.kt 59.45% 5 Missing and 10 partials ⚠️
...ndroid/instrumentation/pans/PansInstrumentation.kt 81.81% 1 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main    #1467      +/-   ##
==========================================
- Coverage   63.63%   63.26%   -0.37%     
==========================================
  Files         159      165       +6     
  Lines        3154     3449     +295     
  Branches      325      355      +30     
==========================================
+ Hits         2007     2182     +175     
- Misses       1049     1150     +101     
- Partials       98      117      +19     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant