-
Notifications
You must be signed in to change notification settings - Fork 81
feat(pans): integrate PANS metrics collection with OpenTelemetry #1467
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat(pans): integrate PANS metrics collection with OpenTelemetry #1467
Conversation
There was a problem hiding this 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, andPANSMetricsExtractorclasses 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
NetStatsManagerandConnectivityManagerWrapperto 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. | ||
| */ |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
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.
| */ | |
| */ | |
| // 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. |
| 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() | ||
| } | ||
| } |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
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.
| <!-- Required to access network statistics data --> | ||
| <!-- This permission is intended for system/automotive apps --> |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
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:
- This permission requires special privileges
- The instrumentation will have limited functionality on regular consumer apps
- It's primarily intended for system-level or automotive apps
This is important for setting correct expectations for users of this library.
| <!-- 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. | |
| --> |
| 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() |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
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:
- Has no way to properly cancel the thread (isRunning check is not sufficient)
- Blocks a thread unnecessarily
- Doesn't handle app lifecycle events
- 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
| private val netStatsManager: NetStatsManager, | ||
| ) { | ||
| private val connectivityManager = ConnectivityManagerWrapper(context) | ||
| private val preferencesCache = mutableMapOf<String, String>() |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
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.
| private val preferencesCache = mutableMapOf<String, String>() |
| @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 | ||
| } | ||
| } |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
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:
- Removing the parameter until API 34+ support is actually implemented
- Adding a TODO comment explaining exactly what needs to be done
- Implementing at least basic usage of the parameter if possible
The current approach makes the function signature misleading.
| 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 | ||
| } | ||
| } |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
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.
| private val sdk: OpenTelemetrySdk, | ||
| private val collectionIntervalMinutes: Long = DEFAULT_COLLECTION_INTERVAL_MINUTES, | ||
| ) { | ||
| private val logger: Meter = sdk.getMeter("io.opentelemetry.android.pans") |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
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.
| 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) | ||
| } | ||
| } |
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
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.
| 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"), | ||
| ), | ||
| ) | ||
| } | ||
|
|
Copilot
AI
Dec 7, 2025
There was a problem hiding this comment.
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.
| 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"), | |
| ), | |
| ) |
Codecov Report❌ Patch coverage is 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. 🚀 New features to boost your workflow:
|
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
PANSMetrics,AppNetworkUsage,PreferenceChange, andNetworkAvailability, along with helper functions to build OpenTelemetry attributes for these metrics (PANSMetrics.kt).ConnectivityManagerandNetworkStatsManagerto safely and conveniently collect network state, availability, and per-app network usage statistics (ConnectivityManagerWrapper.kt,NetStatsManager.kt). [1] [2]Module setup and configuration
build.gradle.kts).consumer-rules.pro).AndroidManifest.xmlto allow network stats collection and monitoring (PACKAGE_USAGE_STATS,ACCESS_NETWORK_STATE,INTERNET).API exposure
pans.api).