A powerful, annotation-based tracking Gradle plugin for Android that eliminates boilerplate code by automatically injecting analytics tracking at compile time using bytecode transformation.
- π― Zero Boilerplate: Just add annotations - no manual lifecycle management
- β‘ Compile-Time Safety: Bytecode injection using ASM and modern AGP APIs
- ποΈ Architecture Agnostic: Works with Activities, Fragments, ViewModels, and more
- π Method-Level Tracking: Track individual method calls with parameters and custom serialization
- π High Performance: Minimal build overhead with incremental build support
- π§ Highly Configurable: Fine-grained control over tracking behavior via Gradle plugin
- π§ͺ Testing Friendly: Built-in debug providers and test utilities
- π± Modern Android: Supports API 26+ with latest Android development practices
For single module projects:
// app/build.gradle.kts
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("dev.moshalan.easyanalytics") version "2.0.0"
}
// Configure the analytics plugin
analytics {
enabled = true
debugMode = false // true if you want to see debug messages during bytecode transformation
}
dependencies {
implementation("dev.moshalan:easy-analytics-core:2.0.0")
}For multi-module projects:
Apply the plugin only to your app module. Feature/library modules just need the dependencies:
// app/build.gradle.kts
plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("dev.moshalan.easyanalytics") version "2.0.0" // Apply plugin here
}
analytics {
enabled = true
debugMode = false
}
dependencies {
implementation(project(":feature:home")) // Your feature modules
implementation("dev.moshalan:easy-analytics-core:2.0.0")
}// feature/home/build.gradle.kts (Library Module)
plugins {
id("com.android.library")
id("org.jetbrains.kotlin.android")
// Don't apply the analytics plugin in library modules
}
dependencies {
implementation("dev.moshalan:easy-analytics-core:2.0.0")
}The plugin will automatically instrument all @TrackScreen annotations in your feature modules when building the app.
class FirebaseAnalyticsProvider(
private val firebaseAnalytics: FirebaseAnalytics
) : AnalyticsProvider {
override fun logEvent(eventName: String, parameters: Map<String, Any>) {
val bundle = Bundle().apply {
parameters.forEach { (key, value) ->
when (value) {
is String -> putString(key, value)
is Int -> putInt(key, value)
is Double -> putDouble(key, value)
is Boolean -> putBoolean(key, value)
else -> putString(key, value.toString())
}
}
}
firebaseAnalytics.logEvent(eventName, bundle)
}
override fun setUserId(userId: String?) {
// Set user ID in Firebase Analytics
}
override fun setUserProperty(key: String, value: String) {
// Set user properties in Firebase Analytics
}
}class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
ScreenTracking.initialize(
config = analyticsConfig {
debugMode = BuildConfig.DEBUG
providers.add(InMemoryDebugAnalyticsProvider())
// Add your analytics providers here
}
)
}
}@TrackScreen(screenName = "Home Screen")
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
// Analytics tracking is automatically injected after super.onCreate()!
}
}@TrackScreen(screenName = "Profile Screen")
class ProfileFragment : Fragment() {
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
// Analytics tracking is automatically injected after super.onViewCreated()!
}
}Track individual method calls with parameters in any class (ViewModels, Services, etc.):
@Trackable // Mark class as containing @Track methods
class UserViewModel : ViewModel() {
@Track(eventName = "user_profile_loaded", includeGlobalParams = true)
fun loadUserProfile(
@Param("user_id") userId: String,
@Param("source") source: String
) {
// Method implementation
// Analytics call is automatically injected at method start!
}
@Track(eventName = "settings_changed", includeGlobalParams = false)
fun updateSettings(@Param("settings") settings: UserSettings) {
// Complex objects are automatically serialized
}
}The plugin automatically injects tracking code at compile time using bytecode transformation with ASM:
- Activities: Code injected after
super.onCreate(savedInstanceState) - Fragments: Code injected after
super.onViewCreated(view, savedInstanceState) - Method Tracking: Calls injected at the beginning of
@Trackannotated methods
The Gradle plugin approach is that:
- Scans for
@TrackScreenand@Trackableannotations during build - Generates bytecode to inject analytics tracking calls
- Provides zero runtime overhead for tracking
- Supports incremental builds for fast compilation
analytics {
enabled = true // Enable/disable plugin
debugMode = true // Verbose logging during bytecode transformation
// Package filtering for performance
includePackages = setOf(
"com.myapp.features",
"com.myapp.screens"
)
excludePackages = setOf(
"com.myapp.internal",
"com.myapp.testing"
)
}class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
ScreenTracking.initialize(
config = analyticsConfig {
debugMode = BuildConfig.DEBUG
// Add analytics providers
providers.add(InMemoryDebugAnalyticsProvider())
providers.add(FirebaseAnalyticsProvider()) // Custom provider
// Global error handler for all analytics operations
errorHandler = { throwable ->
Log.e("Analytics", "Analytics error", throwable)
}
// Configure method tracking (optional)
methodTracking {
enabled = true
customSerializers.add(MyCustomParameterSerializer())
}
}
)
// Set global parameters (optional)
ScreenTracking.setGlobalParameters(mapOf(
"app_version" to BuildConfig.VERSION_NAME,
"build_type" to BuildConfig.BUILD_TYPE
))
}
}Enable debug mode to see detailed transformation logs:
analytics {
debugMode = true
}Sample debug output:
DEBUG: AnalyticsClassVisitor: Found @TrackScreen annotation on class com.myapp.MainActivity
DEBUG: AnalyticsClassVisitor: Injecting Activity tracking for "Home Screen"
DEBUG: AnalyticsClassVisitor: Generated tracking method successfully
analytics-annotation/
βββ annotation/ # Annotations (@TrackScreen, @Trackable, @Track, @Param)
βββ core/ # Core tracking logic, providers, and method tracking
βββ plugin/ # Gradle plugin for bytecode injection
βββ app/ # Sample application with examples
The plugin integrates seamlessly with Android builds:
- Annotation Scanning: Detects
@TrackScreenand@Trackableannotations - Bytecode Transformation: Injects tracking calls using ASM
- Method Instrumentation: Processes
@Trackmethods with parameter serialization - Incremental Support: Only processes changed classes
- Cache Compatible: Works with Gradle build cache
Performance benchmarks on the sample app:
| Metric | Result | Status |
|---|---|---|
| Build Time Impact | Minimal | β Excellent |
| Incremental Build | Supported | β Working |
| Cache Compatibility | Yes | β Working |
| Memory Overhead | Minimal | β Minimal |
The plugin only processes annotated classes during build time, ensuring minimal performance impact.
Testing analytics tracking is essential to ensure your analytics events are properly captured. This library provides several built-in mechanisms and testing utilities to verify your analytics implementation.
The easiest way to test analytics during development is to use the built-in debug providers in your development build:
Logs all tracking events to Logcat for quick verification:
class MyApplication : Application() {
override fun onCreate() {
super.onCreate()
ScreenTracking.initialize(
config = analyticsConfig {
debugMode = BuildConfig.DEBUG
// Add debug provider to see events in Logcat and verify in tests
providers.add(InMemoryDebugAnalyticsProvider())
// Add your production provider
if (!BuildConfig.DEBUG) {
providers.add(FirebaseAnalyticsProvider(Firebase.analytics))
}
}
)
}
}When you navigate through your app, you'll see output like:
D/AnalyticsProvider: Screen tracked: Home Screen with parameters: {category=all, sort=name}
D/AnalyticsProvider: Screen tracked: Product Details with parameters: {product_id=123, price=99.99}
Test your annotated classes in isolation using mock providers:
class AnalyticsTest {
@Test
fun `verify home screen tracking with parameters`() {
// Arrange
val mockProvider = mockk<AnalyticsProvider>()
ScreenTracking.initialize(
config = analyticsConfig {
providers.add(mockProvider)
}
)
// Act
val activity = Robolectric.buildActivity(MainActivity::class.java)
.create()
.start()
.resume()
.get()
// Assert
verify(exactly = 1) {
mockProvider.trackScreen("Home Screen", emptyMap())
}
}
@Test
fun `verify product details tracking with dynamic parameters`() {
// Arrange
val mockProvider = mockk<AnalyticsProvider>()
ScreenTracking.initialize(
config = analyticsConfig {
providers.add(mockProvider)
}
)
val testProduct = Product(
id = "123",
name = "Test Product",
price = 99.99
)
val intent = Intent(applicationContext, ProductDetailsActivity::class.java).apply {
putExtra("product", testProduct)
}
// Act
val activity = Robolectric.buildActivity(ProductDetailsActivity::class.java, intent)
.create()
.start()
.resume()
.get()
// Assert
verify(exactly = 1) {
mockProvider.trackScreen(
"Product Details",
match { params ->
params["product_id"] == "123" &&
params["product_name"] == "Test Product" &&
params["price"] == 99.99
}
)
}
}
}Verify analytics tracking in integration tests using the in-memory debug provider:
class AnalyticsIntegrationTest {
private lateinit var debugProvider: InMemoryDebugAnalyticsProvider
@Before
fun setup() {
debugProvider = InMemoryDebugAnalyticsProvider()
ScreenTracking.initialize(
config = analyticsConfig {
providers.add(debugProvider)
}
)
}
@Test
fun `verify navigation flow tracks all screens`() {
// Act
val activity = Robolectric.buildActivity(MainActivity::class.java)
.create()
.start()
.resume()
.get()
// Navigate to profile
activity.findViewById<Button>(R.id.profile_button).performClick()
// Assert
val events = debugProvider.getLoggedEvents()
assertEquals(2, events.size)
assertEquals("screen_view", events[0].eventName)
assertEquals("screen_view", events[1].eventName)
assertEquals("Home Screen", events[0].parameters["screen_name"])
assertEquals("Profile Screen", events[1].parameters["screen_name"])
}
@Test
fun `verify custom parameters are tracked correctly`() {
// Arrange
val customParams = mapOf(
"user_id" to "user_123",
"subscription_type" to "premium"
)
// Act
ScreenTracking.trackScreen("User Profile", customParams)
// Assert
val events = debugProvider.getLoggedEvents()
assertThat(events).isNotEmpty()
val lastEvent = events.last()
assertEquals("screen_view", lastEvent.eventName)
assertEquals("User Profile", lastEvent.parameters["screen_name"])
assertEquals("user_123", lastEvent.parameters["user_id"])
assertEquals("premium", lastEvent.parameters["subscription_type"])
}
}Verify that Fragment parameters are properly tracked:
@Test
fun `verify fragment with parameters tracking`() {
// Arrange
val debugProvider = InMemoryDebugAnalyticsProvider()
ScreenTracking.initialize(
config = analyticsConfig {
providers.add(debugProvider)
}
)
// Act
val fragment = ProductListFragment().apply {
arguments = Bundle().apply {
putString("category", "electronics")
putString("sort", "price")
}
}
val activity = Robolectric.buildActivity(MainActivity::class.java)
.create()
.start()
.get()
activity.supportFragmentManager.beginTransaction()
.add(android.R.id.content, fragment)
.commitNow()
// Assert
val events = debugProvider.getLoggedEvents()
assertThat(events).isNotEmpty()
val lastEvent = events.last()
assertEquals("screen_view", lastEvent.eventName)
assertEquals("Product List", lastEvent.parameters["screen_name"])
assertEquals("electronics", lastEvent.parameters["category"])
assertEquals("price", lastEvent.parameters["sort"])
}Verify that method-level analytics events are properly tracked:
@Test
fun `verify method tracking with parameters`() {
// Arrange
val mockProvider = mockk<AnalyticsProvider>()
ScreenTracking.initialize(
config = analyticsConfig {
providers.add(mockProvider)
}
)
val viewModel = UserViewModel()
// Act
viewModel.loadUserProfile(
userId = "user_456",
source = "deep_link"
)
// Assert
verify(exactly = 1) {
mockProvider.trackScreen(
"user_profile_loaded",
match { params ->
params["user_id"] == "user_456" &&
params["source"] == "deep_link"
}
)
}
}Create a test provider to capture and verify analytics events in your tests:
class TestAnalyticsProvider : AnalyticsProvider {
private val capturedEvents = mutableListOf<AnalyticsEvent>()
override fun trackScreen(screenName: String, parameters: Map<String, Any>) {
capturedEvents.add(AnalyticsEvent(screenName, parameters, System.currentTimeMillis()))
}
fun getEvents(): List<AnalyticsEvent> = capturedEvents.toList()
fun clear() {
capturedEvents.clear()
}
}
// Usage in tests
@Test
fun `verify complete user journey tracking`() {
// Arrange
val testProvider = TestAnalyticsProvider()
ScreenTracking.initialize(
config = analyticsConfig {
providers.add(testProvider)
}
)
// Act
val activity = Robolectric.buildActivity(MainActivity::class.java)
.create()
.start()
.resume()
.get()
// Navigate through screens
activity.navigateToProfile()
activity.navigateToSettings()
// Assert
val events = testProvider.getEvents()
assertEquals(3, events.size)
assertEquals("Home Screen", events[0].screenName)
assertEquals("Profile Screen", events[1].screenName)
assertEquals("Settings Screen", events[2].screenName)
}Verify analytics tracking during UI tests:
@RunWith(AndroidJUnit4::class)
class AnalyticsUITest {
@get:Rule
val activityRule = ActivityScenario.launch(MainActivity::class.java)
private val debugProvider = InMemoryDebugAnalyticsProvider()
@Before
fun setup() {
ScreenTracking.initialize(
config = analyticsConfig {
providers.add(debugProvider)
}
)
}
@Test
fun testNavigationTrackingInUI() {
// Act
onView(withId(R.id.profile_button)).perform(click())
// Assert
val events = debugProvider.getLoggedEvents()
val profileScreenEvent = events.find { it.parameters["screen_name"] == "Profile Screen" }
assertNotNull(profileScreenEvent)
}
}We welcome contributions! Please see CONTRIBUTING.md for guidelines.
git clone https://github.com/shalan/analytics-annotation.git
cd analytics-annotation
./gradlew build# Build and publish plugin locally
./gradlew :plugin:publishToMavenLocal
# Test plugin with sample app
./gradlew :app:clean :app:build./gradlew test # Unit tests
./gradlew connectedCheck # Integration tests
./gradlew :plugin:test # Plugin testsThis project is licensed under the Apache License 2.0 - see the LICENSE file for details.
- Sample App - Complete example implementation
- Examples - Comprehensive usage examples
- Plugin Documentation - Detailed plugin documentation
- Issues - Report bugs and request features
- Examples: See EXAMPLES.md for comprehensive usage examples
- Issues: Report bugs on GitHub Issues
- Plugin Details: Check plugin/README.md for detailed plugin documentation
Made with β€οΈ for the Android community