From e30a0266769f7e13ce515b1b63fb86f7afdb2911 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Mon, 25 May 2026 04:52:23 +0530 Subject: [PATCH 1/5] chore: add ktlint and apply formatting --- .editorconfig | 21 ++++ build.gradle.kts | 13 +- settings.gradle.kts | 2 +- src/main/kotlin/GoogleAnalytics.kt | 12 +- src/main/kotlin/GoogleAnalyticsBuilder.kt | 36 +++--- src/main/kotlin/GoogleAnalyticsConfig.kt | 7 +- .../kotlin/httpclient/OkHttpClientImpl.kt | 114 ++++++++++-------- src/main/kotlin/internal/GaImpl.kt | 44 +++++-- src/main/kotlin/internal/GaUtils.kt | 8 +- src/main/kotlin/internal/SessionManager.kt | 1 - src/main/kotlin/request/AnyValueSerializer.kt | 60 +++++---- src/main/kotlin/request/BaseHit.kt | 65 +++++++--- src/main/kotlin/request/CustomHit.kt | 7 +- src/main/kotlin/request/EventHit.kt | 18 +-- src/main/kotlin/request/ExceptionHit.kt | 28 +++-- src/main/kotlin/request/GaEvent.kt | 31 ++++- src/main/kotlin/request/GaRequest.kt | 7 +- src/main/kotlin/request/PageViewHit.kt | 8 +- src/main/kotlin/request/ScreenViewHit.kt | 8 +- src/main/kotlin/request/TimingHit.kt | 7 +- .../com/criticalay/AnyValueSerializerTest.kt | 38 +++--- src/test/kotlin/com/criticalay/BaseHitTest.kt | 63 +++++----- .../kotlin/com/criticalay/CustomHitTest.kt | 13 +- .../kotlin/com/criticalay/EventHitTest.kt | 15 +-- .../kotlin/com/criticalay/ExceptionHitTest.kt | 7 +- .../criticalay/GoogleAnalyticsConfigTest.kt | 10 +- .../kotlin/com/criticalay/PageViewHitTest.kt | 9 +- .../com/criticalay/ScreenViewHitTest.kt | 9 +- .../kotlin/com/criticalay/TimingHitTest.kt | 11 +- .../com/criticalay/internal/GaUtilsTest.kt | 1 - .../com/criticalay/request/GaEventTest.kt | 37 +++--- .../com/criticalay/request/GaRequestTest.kt | 1 - .../com/criticalay/response/GaResponseTest.kt | 26 ++-- 33 files changed, 441 insertions(+), 296 deletions(-) create mode 100644 .editorconfig diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..333c6a0 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,21 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{kt,kts}] +indent_size = 4 +max_line_length = 140 +ij_kotlin_allow_trailing_comma = true +ij_kotlin_allow_trailing_comma_on_call_site = true +ktlint_standard_no-wildcard-imports = enabled + +[*.{yml,yaml}] +indent_size = 2 + +[*.md] +trim_trailing_whitespace = false diff --git a/build.gradle.kts b/build.gradle.kts index 0d4e4bf..b136dfe 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ plugins { kotlin("jvm") version "2.3.10" id("com.vanniktech.maven.publish") version "0.36.0" kotlin("plugin.serialization") version "2.3.10" + id("org.jlleitschuh.gradle.ktlint") version "14.2.0" } group = "io.github.criticalay" @@ -49,7 +50,6 @@ dependencies { implementation("io.github.oshai:kotlin-logging-jvm:6.0.9") implementation("org.slf4j:slf4j-api:2.0.13") - testImplementation(kotlin("test")) testImplementation("org.junit.jupiter:junit-jupiter:5.10.0") testImplementation("io.mockk:mockk:1.13.8") @@ -61,4 +61,13 @@ kotlin { tasks.test { useJUnitPlatform() -} \ No newline at end of file +} + +ktlint { + verbose.set(true) + android.set(false) + outputToConsole.set(true) + filter { + exclude("**/build/**") + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 99481f1..cc9fd54 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,4 +1,4 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } -rootProject.name = "google-analytics-kt" \ No newline at end of file +rootProject.name = "google-analytics-kt" diff --git a/src/main/kotlin/GoogleAnalytics.kt b/src/main/kotlin/GoogleAnalytics.kt index fe82279..779f243 100644 --- a/src/main/kotlin/GoogleAnalytics.kt +++ b/src/main/kotlin/GoogleAnalytics.kt @@ -36,7 +36,6 @@ import com.criticalay.response.GaResponse * Event delivery depends on [inSample] and configuration in [config]. */ interface GoogleAnalytics : AutoCloseable { - /** The active configuration. */ val config: GoogleAnalyticsConfig @@ -44,6 +43,7 @@ interface GoogleAnalytics : AutoCloseable { val inSample: Boolean // Hit-type factory methods + /** * Creates a [PageViewHit] builder pre-configured for [clientId]. * Call [PageViewHit.send] (or [PageViewHit.sendAsync]) when ready. @@ -66,7 +66,10 @@ interface GoogleAnalytics : AutoCloseable { * Creates a [CustomHit] builder for an arbitrary GA4 event name. * The [name] must follow GA4 naming rules (letters, digits, underscores, max 40 chars). */ - fun custom(clientId: String, name: String): CustomHit + fun custom( + clientId: String, + name: String, + ): CustomHit /** * Sends a fully constructed [GaRequest] synchronously. @@ -102,7 +105,6 @@ interface GoogleAnalytics : AutoCloseable { * } * ``` */ - fun builder(block: GoogleAnalyticsBuilder.() -> Unit): GoogleAnalytics = - GoogleAnalyticsBuilder().apply(block).build() + fun builder(block: GoogleAnalyticsBuilder.() -> Unit): GoogleAnalytics = GoogleAnalyticsBuilder().apply(block).build() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/GoogleAnalyticsBuilder.kt b/src/main/kotlin/GoogleAnalyticsBuilder.kt index 04fdd41..ea6358d 100644 --- a/src/main/kotlin/GoogleAnalyticsBuilder.kt +++ b/src/main/kotlin/GoogleAnalyticsBuilder.kt @@ -16,8 +16,8 @@ package com.criticalay -import com.criticalay.internal.GaImpl import com.criticalay.httpclient.OkHttpClientImpl +import com.criticalay.internal.GaImpl /** * Builder for configuring and creating a [GoogleAnalytics] instance. @@ -51,7 +51,6 @@ import com.criticalay.httpclient.OkHttpClientImpl * Call [build] to create a fully configured [GoogleAnalytics] instance. */ class GoogleAnalyticsBuilder { - /** GA4 Measurement ID (e.g. "G-XXXXXXXX"). **Required.** */ var measurementId: String = "" @@ -106,22 +105,23 @@ class GoogleAnalyticsBuilder { /** Builds and returns a fully initialized [GoogleAnalytics] instance. */ fun build(): GoogleAnalytics { - val config = GoogleAnalyticsConfig( - measurementId = measurementId, - apiSecret = apiSecret, - appName = appName, - appVersion = appVersion, - enabled = enabled, - debug = debug, - samplePercentage = samplePercentage, - batchingEnabled = batchingEnabled, - batchSize = batchSize, - endpointUrl = endpointUrl, - proxyHost = proxyHost, - proxyPort = proxyPort, - connectTimeoutMs = connectTimeoutMs, - readTimeoutMs = readTimeoutMs, - ) + val config = + GoogleAnalyticsConfig( + measurementId = measurementId, + apiSecret = apiSecret, + appName = appName, + appVersion = appVersion, + enabled = enabled, + debug = debug, + samplePercentage = samplePercentage, + batchingEnabled = batchingEnabled, + batchSize = batchSize, + endpointUrl = endpointUrl, + proxyHost = proxyHost, + proxyPort = proxyPort, + connectTimeoutMs = connectTimeoutMs, + readTimeoutMs = readTimeoutMs, + ) val httpClient = OkHttpClientImpl(config) return GaImpl(config, httpClient) } diff --git a/src/main/kotlin/GoogleAnalyticsConfig.kt b/src/main/kotlin/GoogleAnalyticsConfig.kt index d847158..9dda07d 100644 --- a/src/main/kotlin/GoogleAnalyticsConfig.kt +++ b/src/main/kotlin/GoogleAnalyticsConfig.kt @@ -16,7 +16,6 @@ package com.criticalay - /** * Configuration for the GA4 Measurement Protocol SDK. * @@ -59,7 +58,7 @@ data class GoogleAnalyticsConfig( ) { companion object { const val DEFAULT_ENDPOINT = "https://www.google-analytics.com/mp/collect" - const val DEBUG_ENDPOINT = "https://www.google-analytics.com/debug/mp/collect" + const val DEBUG_ENDPOINT = "https://www.google-analytics.com/debug/mp/collect" } /** Returns the effective endpoint URL (debug or standard). */ @@ -67,8 +66,8 @@ data class GoogleAnalyticsConfig( init { require(measurementId.isNotBlank()) { "measurementId must not be blank" } - require(apiSecret.isNotBlank()) { "apiSecret must not be blank" } + require(apiSecret.isNotBlank()) { "apiSecret must not be blank" } require(samplePercentage in 1..100) { "samplePercentage must be between 1 and 100" } - require(batchSize in 1..25) { "batchSize must be between 1 and 25 (GA4 limit)" } + require(batchSize in 1..25) { "batchSize must be between 1 and 25 (GA4 limit)" } } } diff --git a/src/main/kotlin/httpclient/OkHttpClientImpl.kt b/src/main/kotlin/httpclient/OkHttpClientImpl.kt index db0548e..a5e19eb 100644 --- a/src/main/kotlin/httpclient/OkHttpClientImpl.kt +++ b/src/main/kotlin/httpclient/OkHttpClientImpl.kt @@ -39,7 +39,6 @@ import java.net.InetSocketAddress import java.net.Proxy import java.util.concurrent.TimeUnit - private val logger = KotlinLogging.logger {} /** @@ -53,20 +52,24 @@ private val logger = KotlinLogging.logger {} * - Parses validation messages from the debug endpoint response body. * - Executes HTTP calls on [Dispatchers.IO]. */ -class OkHttpClientImpl(private val config: GoogleAnalyticsConfig) : AutoCloseable { - +class OkHttpClientImpl( + private val config: GoogleAnalyticsConfig, +) : AutoCloseable { @OptIn(kotlinx.serialization.ExperimentalSerializationApi::class) - private val json = Json { - encodeDefaults = false - explicitNulls = false - } + private val json = + Json { + encodeDefaults = false + explicitNulls = false + } private val client: OkHttpClient = buildClient() private fun buildClient(): OkHttpClient { - val builder = OkHttpClient.Builder() - .connectTimeout(config.connectTimeoutMs, TimeUnit.MILLISECONDS) - .readTimeout(config.readTimeoutMs, TimeUnit.MILLISECONDS) + val builder = + OkHttpClient + .Builder() + .connectTimeout(config.connectTimeoutMs, TimeUnit.MILLISECONDS) + .readTimeout(config.readTimeoutMs, TimeUnit.MILLISECONDS) if (!config.proxyHost.isNullOrBlank() && config.proxyPort > 0) { logger.debug { "Configuring proxy: ${config.proxyHost}:${config.proxyPort}" } @@ -74,7 +77,7 @@ class OkHttpClientImpl(private val config: GoogleAnalyticsConfig) : AutoCloseabl Proxy( Proxy.Type.HTTP, InetSocketAddress(config.proxyHost, config.proxyPort), - ) + ), ) } @@ -87,40 +90,44 @@ class OkHttpClientImpl(private val config: GoogleAnalyticsConfig) : AutoCloseabl * * @return [GaResponse] with the HTTP status code and any debug validation messages. */ - suspend fun post(request: GaRequest): GaResponse = withContext(Dispatchers.IO) { - val bodyJson = json.encodeToString(request) - logger.debug { "GA4 request body: $bodyJson" } - - val url = buildUrl() - val okRequest = Request.Builder() - .url(url) - .post(bodyJson.toRequestBody("application/json; charset=utf-8".toMediaType())) - .build() - - try { - client.newCall(okRequest).execute().use { response -> - val statusCode = response.code - val responseBody = response.body?.string() ?: "" - - logger.debug { "GA4 response: $statusCode — $responseBody" } - - val validationMessages = if (config.debug && responseBody.isNotBlank()) { - parseValidationMessages(responseBody) - } else { - emptyList() + suspend fun post(request: GaRequest): GaResponse = + withContext(Dispatchers.IO) { + val bodyJson = json.encodeToString(request) + logger.debug { "GA4 request body: $bodyJson" } + + val url = buildUrl() + val okRequest = + Request + .Builder() + .url(url) + .post(bodyJson.toRequestBody("application/json; charset=utf-8".toMediaType())) + .build() + + try { + client.newCall(okRequest).execute().use { response -> + val statusCode = response.code + val responseBody = response.body?.string() ?: "" + + logger.debug { "GA4 response: $statusCode — $responseBody" } + + val validationMessages = + if (config.debug && responseBody.isNotBlank()) { + parseValidationMessages(responseBody) + } else { + emptyList() + } + + GaResponse( + statusCode = statusCode, + requestBody = bodyJson, + validationMessages = validationMessages, + ) } - - GaResponse( - statusCode = statusCode, - requestBody = bodyJson, - validationMessages = validationMessages, - ) + } catch (e: Exception) { + logger.error(e) { "Failed to send GA4 request to $url" } + GaResponse(statusCode = -1, requestBody = bodyJson) } - } catch (e: Exception) { - logger.error(e) { "Failed to send GA4 request to $url" } - GaResponse(statusCode = -1, requestBody = bodyJson) } - } /** * Builds the full POST URL with required query parameters. @@ -129,10 +136,14 @@ class OkHttpClientImpl(private val config: GoogleAnalyticsConfig) : AutoCloseabl * Debug: `https://www.google-analytics.com/debug/mp/collect?measurement_id=G-XXX&api_secret=XXX` */ private fun buildUrl(): String { - val base = config.effectiveEndpointUrl().toHttpUrl().newBuilder() - .addQueryParameter("measurement_id", config.measurementId) - .addQueryParameter("api_secret", config.apiSecret) - .build() + val base = + config + .effectiveEndpointUrl() + .toHttpUrl() + .newBuilder() + .addQueryParameter("measurement_id", config.measurementId) + .addQueryParameter("api_secret", config.apiSecret) + .build() return base.toString() } @@ -155,14 +166,15 @@ class OkHttpClientImpl(private val config: GoogleAnalyticsConfig) : AutoCloseabl private fun parseValidationMessages(responseBody: String): List { return try { val root: JsonObject = Json.parseToJsonElement(responseBody).jsonObject - val messagesArray: JsonArray = root["validationMessages"]?.jsonArray - ?: return emptyList() + val messagesArray: JsonArray = + root["validationMessages"]?.jsonArray + ?: return emptyList() messagesArray.map { element -> val obj = element.jsonObject ValidationMessage( - fieldPath = obj["fieldPath"]?.jsonPrimitive?.content ?: "", - description = obj["description"]?.jsonPrimitive?.content ?: "", + fieldPath = obj["fieldPath"]?.jsonPrimitive?.content ?: "", + description = obj["description"]?.jsonPrimitive?.content ?: "", validationCode = obj["validationCode"]?.jsonPrimitive?.content ?: "", ) } @@ -177,4 +189,4 @@ class OkHttpClientImpl(private val config: GoogleAnalyticsConfig) : AutoCloseabl client.connectionPool.evictAll() client.cache?.close() } -} \ No newline at end of file +} diff --git a/src/main/kotlin/internal/GaImpl.kt b/src/main/kotlin/internal/GaImpl.kt index e148308..41c8cfd 100644 --- a/src/main/kotlin/internal/GaImpl.kt +++ b/src/main/kotlin/internal/GaImpl.kt @@ -29,7 +29,12 @@ import com.criticalay.request.ScreenViewHit import com.criticalay.request.TimingHit import com.criticalay.response.GaResponse import io.github.oshai.kotlinlogging.KotlinLogging -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import java.util.concurrent.atomic.AtomicLong @@ -50,7 +55,6 @@ class GaImpl( override val config: GoogleAnalyticsConfig, private val httpClient: OkHttpClientImpl, ) : GoogleAnalytics { - private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) // Mutex protecting the mutable batch buffer @@ -80,11 +84,19 @@ class GaImpl( } override fun pageView(clientId: String): PageViewHit = PageViewHit(clientId, this) + override fun event(clientId: String): EventHit = EventHit(clientId, this) + override fun screenView(clientId: String): ScreenViewHit = ScreenViewHit(clientId, this) + override fun exception(clientId: String): ExceptionHit = ExceptionHit(clientId, this) + override fun timing(clientId: String): TimingHit = TimingHit(clientId, this) - override fun custom(clientId: String, name: String): CustomHit = CustomHit(clientId, name, this) + + override fun custom( + clientId: String, + name: String, + ): CustomHit = CustomHit(clientId, name, this) override suspend fun send(request: GaRequest): GaResponse { if (!config.enabled) { @@ -141,10 +153,11 @@ class GaImpl( // For batched sends we use a synthetic clientId — in a real app the // caller should use per-user clientIds via individual requests. // If batching is used, it's assumed all events share a logical sender. - val batchRequest = GaRequest( - clientId = "batch-flush", - events = chunk.take(25), // hard GA4 limit - ) + val batchRequest = + GaRequest( + clientId = "batch-flush", + events = chunk.take(25), // hard GA4 limit + ) val response = httpClient.post(batchRequest) logValidationWarnings(response) } @@ -162,9 +175,10 @@ class GaImpl( } /** Returns a snapshot of hit counts keyed by event name. */ - suspend fun stats(): Map = countersMutex.withLock { - hitCounters.mapValues { it.value.get() } - } + suspend fun stats(): Map = + countersMutex.withLock { + hitCounters.mapValues { it.value.get() } + } private fun logValidationWarnings(response: GaResponse) { if (response.validationMessages.isNotEmpty()) { @@ -178,10 +192,16 @@ class GaImpl( override fun close() { runBlocking { - try { flush() } catch (_: Exception) {} + try { + flush() + } catch (_: Exception) { + } } scope.cancel() - try { httpClient.close() } catch (_: Exception) {} + try { + httpClient.close() + } catch (_: Exception) { + } logger.info { "GA4: SDK closed" } } } diff --git a/src/main/kotlin/internal/GaUtils.kt b/src/main/kotlin/internal/GaUtils.kt index 7b9ef1f..e5ff1cc 100644 --- a/src/main/kotlin/internal/GaUtils.kt +++ b/src/main/kotlin/internal/GaUtils.kt @@ -14,14 +14,12 @@ * limitations under the License. */ - package com.criticalay.internal /** * Lightweight utility functions for the GA4 SDK. */ internal object GaUtils { - /** Returns true if the string is null, empty, or blank. */ fun isBlank(value: String?): Boolean = value.isNullOrBlank() @@ -44,9 +42,9 @@ internal object GaUtils { * @throws IllegalArgumentException if the name is invalid. */ fun validateEventName(name: String) { - require(name.isNotBlank()) { "Event name must not be blank" } - require(name.length <= 40) { "Event name must not exceed 40 characters: '$name'" } - require(name[0].isLetter()) { "Event name must start with a letter: '$name'" } + require(name.isNotBlank()) { "Event name must not be blank" } + require(name.length <= 40) { "Event name must not exceed 40 characters: '$name'" } + require(name[0].isLetter()) { "Event name must start with a letter: '$name'" } require(name.all { it.isLetterOrDigit() || it == '_' }) { "Event name must contain only letters, digits, and underscores: '$name'" } diff --git a/src/main/kotlin/internal/SessionManager.kt b/src/main/kotlin/internal/SessionManager.kt index 799974e..d45a1df 100644 --- a/src/main/kotlin/internal/SessionManager.kt +++ b/src/main/kotlin/internal/SessionManager.kt @@ -14,7 +14,6 @@ * limitations under the License. */ - package com.criticalay.internal /** diff --git a/src/main/kotlin/request/AnyValueSerializer.kt b/src/main/kotlin/request/AnyValueSerializer.kt index 8fc8f11..53f991a 100644 --- a/src/main/kotlin/request/AnyValueSerializer.kt +++ b/src/main/kotlin/request/AnyValueSerializer.kt @@ -21,7 +21,16 @@ import kotlinx.serialization.descriptors.SerialDescriptor import kotlinx.serialization.descriptors.buildClassSerialDescriptor import kotlinx.serialization.encoding.Decoder import kotlinx.serialization.encoding.Encoder -import kotlinx.serialization.json.* +import kotlinx.serialization.json.JsonDecoder +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonEncoder +import kotlinx.serialization.json.JsonPrimitive +import kotlinx.serialization.json.boolean +import kotlinx.serialization.json.booleanOrNull +import kotlinx.serialization.json.double +import kotlinx.serialization.json.doubleOrNull +import kotlinx.serialization.json.long +import kotlinx.serialization.json.longOrNull /** * Custom kotlinx.Serialization [KSerializer] that can encode heterogeneous @@ -32,32 +41,39 @@ import kotlinx.serialization.json.* object AnyValueSerializer : KSerializer { override val descriptor: SerialDescriptor = buildClassSerialDescriptor("AnyValue") - override fun serialize(encoder: Encoder, value: Any) { - val jsonEncoder = encoder as? JsonEncoder - ?: error("AnyValueSerializer only supports JSON encoding") - val element: JsonElement = when (value) { - is String -> JsonPrimitive(value) - is Int -> JsonPrimitive(value) - is Long -> JsonPrimitive(value) - is Double -> JsonPrimitive(value) - is Float -> JsonPrimitive(value.toDouble()) - is Boolean -> JsonPrimitive(value) - else -> JsonPrimitive(value.toString()) - } + override fun serialize( + encoder: Encoder, + value: Any, + ) { + val jsonEncoder = + encoder as? JsonEncoder + ?: error("AnyValueSerializer only supports JSON encoding") + val element: JsonElement = + when (value) { + is String -> JsonPrimitive(value) + is Int -> JsonPrimitive(value) + is Long -> JsonPrimitive(value) + is Double -> JsonPrimitive(value) + is Float -> JsonPrimitive(value.toDouble()) + is Boolean -> JsonPrimitive(value) + else -> JsonPrimitive(value.toString()) + } jsonEncoder.encodeJsonElement(element) } override fun deserialize(decoder: Decoder): Any { - val jsonDecoder = decoder as? JsonDecoder - ?: error("AnyValueSerializer only supports JSON decoding") + val jsonDecoder = + decoder as? JsonDecoder + ?: error("AnyValueSerializer only supports JSON decoding") return when (val element = jsonDecoder.decodeJsonElement()) { - is JsonPrimitive -> when { - element.isString -> element.content - element.booleanOrNull != null -> element.boolean - element.longOrNull != null -> element.long - element.doubleOrNull != null -> element.double - else -> element.content - } + is JsonPrimitive -> + when { + element.isString -> element.content + element.booleanOrNull != null -> element.boolean + element.longOrNull != null -> element.long + element.doubleOrNull != null -> element.double + else -> element.content + } else -> element.toString() } } diff --git a/src/main/kotlin/request/BaseHit.kt b/src/main/kotlin/request/BaseHit.kt index 68a2640..6048f9b 100644 --- a/src/main/kotlin/request/BaseHit.kt +++ b/src/main/kotlin/request/BaseHit.kt @@ -53,19 +53,21 @@ abstract class BaseHit>( * Session identifier — required for user activity to appear in standard * GA4 reports. Usually a Unix-epoch-second string or UUID. */ - fun sessionId(value: String): T = (this as T).also { - event.param("session_id", value) - sessionId = value - } + fun sessionId(value: String): T = + (this as T).also { + event.param("session_id", value) + sessionId = value + } /** * Engagement time in milliseconds. Required for sessions to count as * "engaged sessions" in GA4 reports. Minimum recommended value: 1. */ - fun engagementTimeMs(value: Long): T = (this as T).also { - event.param("engagement_time_msec", value) - engagementTimeMs = value - } + fun engagementTimeMs(value: Long): T = + (this as T).also { + event.param("engagement_time_msec", value) + engagementTimeMs = value + } /** * Unix timestamp in **microseconds** for when the event occurred. @@ -74,16 +76,39 @@ abstract class BaseHit>( fun timestampMicros(value: Long): T = (this as T).also { timestampMicros = value } /** Adds a user-scoped custom property. */ - fun userProperty(key: String, value: String): T = (this as T).also { - userProperties[key] = UserPropertyValue(value) - } + fun userProperty( + key: String, + value: String, + ): T = + (this as T).also { + userProperties[key] = UserPropertyValue(value) + } /** Adds an arbitrary event-level parameter. */ - fun param(key: String, value: String): T = (this as T).also { event.param(key, value) } - fun param(key: String, value: Int): T = (this as T).also { event.param(key, value) } - fun param(key: String, value: Long): T = (this as T).also { event.param(key, value) } - fun param(key: String, value: Double): T = (this as T).also { event.param(key, value) } - fun param(key: String, value: Boolean): T = (this as T).also { event.param(key, value) } + fun param( + key: String, + value: String, + ): T = (this as T).also { event.param(key, value) } + + fun param( + key: String, + value: Int, + ): T = (this as T).also { event.param(key, value) } + + fun param( + key: String, + value: Long, + ): T = (this as T).also { event.param(key, value) } + + fun param( + key: String, + value: Double, + ): T = (this as T).also { event.param(key, value) } + + fun param( + key: String, + value: Boolean, + ): T = (this as T).also { event.param(key, value) } /** Builds the [GaRequest] from this hit's state. */ fun buildRequest(): GaRequest { @@ -94,11 +119,11 @@ abstract class BaseHit>( event.param("engagement_time_msec", 1L) } return GaRequest( - clientId = clientId, - userId = userId, + clientId = clientId, + userId = userId, timestampMicros = timestampMicros, - userProperties = userProperties, - events = listOf(event), + userProperties = userProperties, + events = listOf(event), ) } diff --git a/src/main/kotlin/request/CustomHit.kt b/src/main/kotlin/request/CustomHit.kt index d275331..883558b 100644 --- a/src/main/kotlin/request/CustomHit.kt +++ b/src/main/kotlin/request/CustomHit.kt @@ -41,5 +41,8 @@ import com.criticalay.GoogleAnalytics * .send() * ``` */ -class CustomHit(clientId: String, eventName: String, ga: GoogleAnalytics) - : BaseHit(eventName, clientId, ga) +class CustomHit( + clientId: String, + eventName: String, + ga: GoogleAnalytics, +) : BaseHit(eventName, clientId, ga) diff --git a/src/main/kotlin/request/EventHit.kt b/src/main/kotlin/request/EventHit.kt index c077b90..e87ed1d 100644 --- a/src/main/kotlin/request/EventHit.kt +++ b/src/main/kotlin/request/EventHit.kt @@ -34,14 +34,16 @@ import com.criticalay.GoogleAnalytics * .send() * ``` */ -class EventHit(clientId: String, ga: GoogleAnalytics) - : BaseHit("event", clientId, ga) { - - fun eventName(name: String): EventHit = apply { - // Mutate the underlying Ga4Event's name via a replacement — we copy into params - // and set a special internal key used by Ga4Impl to override the name. - event.param("_event_name_override", name) - } +class EventHit( + clientId: String, + ga: GoogleAnalytics, +) : BaseHit("event", clientId, ga) { + fun eventName(name: String): EventHit = + apply { + // Mutate the underlying Ga4Event's name via a replacement — we copy into params + // and set a special internal key used by Ga4Impl to override the name. + event.param("_event_name_override", name) + } /** The event category (stored as the `event_category` parameter). */ fun category(value: String): EventHit = apply { event.param("event_category", value) } diff --git a/src/main/kotlin/request/ExceptionHit.kt b/src/main/kotlin/request/ExceptionHit.kt index 0a94cf0..c62943a 100644 --- a/src/main/kotlin/request/ExceptionHit.kt +++ b/src/main/kotlin/request/ExceptionHit.kt @@ -29,9 +29,10 @@ import com.criticalay.GoogleAnalytics * .send() * ``` */ -class ExceptionHit(clientId: String, ga: GoogleAnalytics) - : BaseHit("exception", clientId, ga) { - +class ExceptionHit( + clientId: String, + ga: GoogleAnalytics, +) : BaseHit("exception", clientId, ga) { /** * A description of the exception (max 150 characters in UA, no hard limit in GA4). * Stored in the `description` event parameter. @@ -42,15 +43,20 @@ class ExceptionHit(clientId: String, ga: GoogleAnalytics) * Convenience overload that records the exception message and optionally the * abbreviated stack trace as the description. */ - fun exception(e: Throwable, includeStack: Boolean = false): ExceptionHit = apply { - val desc = if (includeStack) { - val stack = e.stackTrace.take(5).joinToString(" | ") { it.toString() } - "${e::class.simpleName}: ${e.message} @ $stack" - } else { - "${e::class.simpleName}: ${e.message}" + fun exception( + e: Throwable, + includeStack: Boolean = false, + ): ExceptionHit = + apply { + val desc = + if (includeStack) { + val stack = e.stackTrace.take(5).joinToString(" | ") { it.toString() } + "${e::class.simpleName}: ${e.message} @ $stack" + } else { + "${e::class.simpleName}: ${e.message}" + } + event.param("description", desc.take(500)) } - event.param("description", desc.take(500)) - } /** * Whether the exception was fatal (caused the application to crash). diff --git a/src/main/kotlin/request/GaEvent.kt b/src/main/kotlin/request/GaEvent.kt index f92e7cd..e46c5ad 100644 --- a/src/main/kotlin/request/GaEvent.kt +++ b/src/main/kotlin/request/GaEvent.kt @@ -31,22 +31,41 @@ import kotlinx.serialization.Serializable @Serializable data class GaEvent( val name: String, - val params: MutableMap = mutableMapOf(), + val params: MutableMap< + String, + @Serializable(with = AnyValueSerializer::class) + Any, + > = mutableMapOf(), ) { /** Adds or replaces a string parameter. */ - fun param(key: String, value: String): GaEvent = apply { params[key] = value } + fun param( + key: String, + value: String, + ): GaEvent = apply { params[key] = value } /** Adds or replaces an integer parameter. */ - fun param(key: String, value: Int): GaEvent = apply { params[key] = value } + fun param( + key: String, + value: Int, + ): GaEvent = apply { params[key] = value } /** Adds or replaces a long parameter. */ - fun param(key: String, value: Long): GaEvent = apply { params[key] = value } + fun param( + key: String, + value: Long, + ): GaEvent = apply { params[key] = value } /** Adds or replaces a double parameter. */ - fun param(key: String, value: Double): GaEvent = apply { params[key] = value } + fun param( + key: String, + value: Double, + ): GaEvent = apply { params[key] = value } /** Adds or replaces a boolean parameter. */ - fun param(key: String, value: Boolean): GaEvent = apply { params[key] = value } + fun param( + key: String, + value: Boolean, + ): GaEvent = apply { params[key] = value } /** Bulk-adds parameters from a map. */ fun params(map: Map): GaEvent = apply { params.putAll(map) } diff --git a/src/main/kotlin/request/GaRequest.kt b/src/main/kotlin/request/GaRequest.kt index 9a2a46c..186fa3c 100644 --- a/src/main/kotlin/request/GaRequest.kt +++ b/src/main/kotlin/request/GaRequest.kt @@ -41,19 +41,14 @@ import kotlinx.serialization.Serializable data class GaRequest( @SerialName("client_id") val clientId: String? = null, - @SerialName("app_instance_id") val appInstanceId: String? = null, - @SerialName("user_id") val userId: String? = null, - @SerialName("timestamp_micros") val timestampMicros: Long? = null, - @SerialName("user_properties") val userProperties: Map = emptyMap(), - val events: List = emptyList(), ) { init { @@ -61,7 +56,7 @@ data class GaRequest( "Either clientId or appInstanceId must be provided" } require(events.isNotEmpty()) { "At least one event is required" } - require(events.size <= 25) { "Maximum 25 events per request (GA4 limit)" } + require(events.size <= 25) { "Maximum 25 events per request (GA4 limit)" } } } diff --git a/src/main/kotlin/request/PageViewHit.kt b/src/main/kotlin/request/PageViewHit.kt index 483d0dd..72194ee 100644 --- a/src/main/kotlin/request/PageViewHit.kt +++ b/src/main/kotlin/request/PageViewHit.kt @@ -18,7 +18,6 @@ package com.criticalay.request import com.criticalay.GoogleAnalytics - /** * Hit builder for the GA4 `page_view` event. * @@ -32,9 +31,10 @@ import com.criticalay.GoogleAnalytics * .send() * ``` */ -class PageViewHit(clientId: String, ga: GoogleAnalytics) - : BaseHit("page_view", clientId, ga) { - +class PageViewHit( + clientId: String, + ga: GoogleAnalytics, +) : BaseHit("page_view", clientId, ga) { /** * Full URL of the page being viewed. * Equivalent to the `dl` (document location) parameter in UA. diff --git a/src/main/kotlin/request/ScreenViewHit.kt b/src/main/kotlin/request/ScreenViewHit.kt index 0fa1dee..be5ca54 100644 --- a/src/main/kotlin/request/ScreenViewHit.kt +++ b/src/main/kotlin/request/ScreenViewHit.kt @@ -18,7 +18,6 @@ package com.criticalay.request import com.criticalay.GoogleAnalytics - /** * Hit builder for the GA4 `screen_view` event. * @@ -32,9 +31,10 @@ import com.criticalay.GoogleAnalytics * .send() * ``` */ -class ScreenViewHit(clientId: String, ga: GoogleAnalytics) - : BaseHit("screen_view", clientId, ga) { - +class ScreenViewHit( + clientId: String, + ga: GoogleAnalytics, +) : BaseHit("screen_view", clientId, ga) { /** The screen name or identifier the user is currently viewing. */ fun screenName(name: String): ScreenViewHit = apply { event.param("screen_name", name) } diff --git a/src/main/kotlin/request/TimingHit.kt b/src/main/kotlin/request/TimingHit.kt index 335e208..9443988 100644 --- a/src/main/kotlin/request/TimingHit.kt +++ b/src/main/kotlin/request/TimingHit.kt @@ -32,9 +32,10 @@ import com.criticalay.GoogleAnalytics * .send() * ``` */ -class TimingHit(clientId: String, ga: GoogleAnalytics) - : BaseHit("timing_complete", clientId, ga) { - +class TimingHit( + clientId: String, + ga: GoogleAnalytics, +) : BaseHit("timing_complete", clientId, ga) { /** * A category for the timed operation (e.g. "api", "db", "render"). * Stored in the `event_category` parameter. diff --git a/src/test/kotlin/com/criticalay/AnyValueSerializerTest.kt b/src/test/kotlin/com/criticalay/AnyValueSerializerTest.kt index 0331bd7..5d790c4 100644 --- a/src/test/kotlin/com/criticalay/AnyValueSerializerTest.kt +++ b/src/test/kotlin/com/criticalay/AnyValueSerializerTest.kt @@ -28,11 +28,13 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows class AnyValueSerializerTest { - private val json = Json { - serializersModule = SerializersModule { - contextual(AnyValueSerializer) + private val json = + Json { + serializersModule = + SerializersModule { + contextual(AnyValueSerializer) + } } - } @Test fun `should serialize various types to JSON primitives`() { @@ -70,21 +72,23 @@ class AnyValueSerializerTest { */ @Test fun `should work within a map structure`() { - val params = mapOf( - "category" to "UI", - "count" to 42, - "is_valid" to false - ) + val params = + mapOf( + "category" to "UI", + "count" to 42, + "is_valid" to false, + ) - val jsonOutput = json.encodeToString( - MapSerializer( - serializer(), - AnyValueSerializer - ), - params - ) + val jsonOutput = + json.encodeToString( + MapSerializer( + serializer(), + AnyValueSerializer, + ), + params, + ) val expected = """{"category":"UI","count":42,"is_valid":false}""" assertEquals(expected, jsonOutput) } -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/criticalay/BaseHitTest.kt b/src/test/kotlin/com/criticalay/BaseHitTest.kt index 16a0cdc..287f931 100644 --- a/src/test/kotlin/com/criticalay/BaseHitTest.kt +++ b/src/test/kotlin/com/criticalay/BaseHitTest.kt @@ -17,7 +17,9 @@ package com.criticalay import com.criticalay.request.EventHit +import com.criticalay.request.GaRequest import com.criticalay.request.UserPropertyValue +import com.criticalay.response.GaResponse import io.mockk.coEvery import io.mockk.coVerify import io.mockk.mockk @@ -29,8 +31,6 @@ import org.junit.jupiter.api.Assertions.assertNotNull import org.junit.jupiter.api.Assertions.assertNull import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test -import com.criticalay.request.GaRequest -import com.criticalay.response.GaResponse class BaseHitTest { private val mockGa = mockk(relaxed = true) @@ -57,9 +57,10 @@ class BaseHitTest { @Test fun `userProperty stores wrapped values in the userProperties map`() { - val hit = EventHit("c1", mockGa) - .userProperty("plan", "premium") - .userProperty("locale", "en_IN") + val hit = + EventHit("c1", mockGa) + .userProperty("plan", "premium") + .userProperty("locale", "en_IN") assertEquals(UserPropertyValue("premium"), hit.userProperties["plan"]) assertEquals(UserPropertyValue("en_IN"), hit.userProperties["locale"]) @@ -74,12 +75,13 @@ class BaseHitTest { @Test fun `param overloads cover all primitive types`() { - val hit = EventHit("c1", mockGa) - .param("s", "v") - .param("i", 1) - .param("l", 2L) - .param("d", 3.0) - .param("b", true) + val hit = + EventHit("c1", mockGa) + .param("s", "v") + .param("i", 1) + .param("l", 2L) + .param("d", 3.0) + .param("b", true) val params = hit.event.params assertEquals("v", params["s"]) @@ -101,9 +103,10 @@ class BaseHitTest { @Test fun `buildRequest preserves explicit session_id and engagement_time_msec`() { - val hit = EventHit("c1", mockGa) - .sessionId("explicit-session") - .engagementTimeMs(5000L) + val hit = + EventHit("c1", mockGa) + .sessionId("explicit-session") + .engagementTimeMs(5000L) val request = hit.buildRequest() val event = request.events.single() @@ -113,10 +116,11 @@ class BaseHitTest { @Test fun `buildRequest carries clientId, userId, timestamp and user properties`() { - val hit = EventHit("client-42", mockGa) - .userId("user-1") - .timestampMicros(1_700_000_000_000_000L) - .userProperty("plan", "free") + val hit = + EventHit("client-42", mockGa) + .userId("user-1") + .timestampMicros(1_700_000_000_000_000L) + .userProperty("plan", "free") val request = hit.buildRequest() assertEquals("client-42", request.clientId) @@ -134,17 +138,18 @@ class BaseHitTest { } @Test - fun `send delegates to the GoogleAnalytics instance with the built request`() = runBlocking { - val captured = slot() - coEvery { mockGa.send(capture(captured)) } returns GaResponse(statusCode = 204) - - val response = EventHit("c1", mockGa).category("ui").send() - - coVerify(exactly = 1) { mockGa.send(any()) } - assertEquals(204, response.statusCode) - assertEquals("c1", captured.captured.clientId) - assertTrue(captured.captured.events.isNotEmpty()) - } + fun `send delegates to the GoogleAnalytics instance with the built request`() = + runBlocking { + val captured = slot() + coEvery { mockGa.send(capture(captured)) } returns GaResponse(statusCode = 204) + + val response = EventHit("c1", mockGa).category("ui").send() + + coVerify(exactly = 1) { mockGa.send(any()) } + assertEquals(204, response.statusCode) + assertEquals("c1", captured.captured.clientId) + assertTrue(captured.captured.events.isNotEmpty()) + } @Test fun `sendAsync delegates to the GoogleAnalytics instance`() { diff --git a/src/test/kotlin/com/criticalay/CustomHitTest.kt b/src/test/kotlin/com/criticalay/CustomHitTest.kt index fe1b00f..8c25d5c 100644 --- a/src/test/kotlin/com/criticalay/CustomHitTest.kt +++ b/src/test/kotlin/com/criticalay/CustomHitTest.kt @@ -32,12 +32,13 @@ class CustomHitTest { @Test fun `custom hit accepts arbitrary parameters via inherited param methods`() { - val hit = CustomHit("client-1", "tool_used", mockGa) - .param("tool_name", "brush") - .param("duration_ms", 3400L) - .param("canvas_width", 1920) - .param("flag", true) - .param("ratio", 1.5) + val hit = + CustomHit("client-1", "tool_used", mockGa) + .param("tool_name", "brush") + .param("duration_ms", 3400L) + .param("canvas_width", 1920) + .param("flag", true) + .param("ratio", 1.5) val params = hit.event.params assertEquals("brush", params["tool_name"]) diff --git a/src/test/kotlin/com/criticalay/EventHitTest.kt b/src/test/kotlin/com/criticalay/EventHitTest.kt index 31cc603..2ebd578 100644 --- a/src/test/kotlin/com/criticalay/EventHitTest.kt +++ b/src/test/kotlin/com/criticalay/EventHitTest.kt @@ -18,19 +18,20 @@ package com.criticalay import com.criticalay.request.EventHit import io.mockk.mockk -import org.junit.jupiter.api.Test import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Test class EventHitTest { private val mockGa = mockk(relaxed = true) @Test fun `test event hit mapping to parameters`() { - val eventHit = EventHit("client-id", mockGa) - .category("feature") - .action("opened") - .label("dark_mode") - .value(10) + val eventHit = + EventHit("client-id", mockGa) + .category("feature") + .action("opened") + .label("dark_mode") + .value(10) val params = eventHit.event.params @@ -39,4 +40,4 @@ class EventHitTest { assertEquals("dark_mode", params["event_label"]) assertEquals(10, params["value"]) } -} \ No newline at end of file +} diff --git a/src/test/kotlin/com/criticalay/ExceptionHitTest.kt b/src/test/kotlin/com/criticalay/ExceptionHitTest.kt index 12efd05..bb100d6 100644 --- a/src/test/kotlin/com/criticalay/ExceptionHitTest.kt +++ b/src/test/kotlin/com/criticalay/ExceptionHitTest.kt @@ -28,9 +28,10 @@ class ExceptionHitTest { @Test fun `description and fatal flag map to GA4 parameters`() { - val hit = ExceptionHit("client-1", mockGa) - .description("NPE at Foo.kt:42") - .fatal(true) + val hit = + ExceptionHit("client-1", mockGa) + .description("NPE at Foo.kt:42") + .fatal(true) val params = hit.event.params assertEquals("NPE at Foo.kt:42", params["description"]) diff --git a/src/test/kotlin/com/criticalay/GoogleAnalyticsConfigTest.kt b/src/test/kotlin/com/criticalay/GoogleAnalyticsConfigTest.kt index 603b19e..ea865b7 100644 --- a/src/test/kotlin/com/criticalay/GoogleAnalyticsConfigTest.kt +++ b/src/test/kotlin/com/criticalay/GoogleAnalyticsConfigTest.kt @@ -22,7 +22,6 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows class GoogleAnalyticsConfigTest { - private fun validConfig( measurementId: String = "G-TEST", apiSecret: String = "secret", @@ -103,10 +102,11 @@ class GoogleAnalyticsConfigTest { @Test fun `effectiveEndpointUrl should ignore custom endpointUrl when debug is true`() { - val config = validConfig( - debug = true, - endpointUrl = "https://example.com/collect", - ) + val config = + validConfig( + debug = true, + endpointUrl = "https://example.com/collect", + ) assertEquals(GoogleAnalyticsConfig.DEBUG_ENDPOINT, config.effectiveEndpointUrl()) } diff --git a/src/test/kotlin/com/criticalay/PageViewHitTest.kt b/src/test/kotlin/com/criticalay/PageViewHitTest.kt index f9e155a..47f8d7b 100644 --- a/src/test/kotlin/com/criticalay/PageViewHitTest.kt +++ b/src/test/kotlin/com/criticalay/PageViewHitTest.kt @@ -26,10 +26,11 @@ class PageViewHitTest { @Test fun `page view hit maps fields to GA4 parameters`() { - val hit = PageViewHit("client-1", mockGa) - .pageLocation("https://app.com/home") - .pageTitle("Home") - .pageReferrer("https://google.com") + val hit = + PageViewHit("client-1", mockGa) + .pageLocation("https://app.com/home") + .pageTitle("Home") + .pageReferrer("https://google.com") val params = hit.event.params assertEquals("https://app.com/home", params["page_location"]) diff --git a/src/test/kotlin/com/criticalay/ScreenViewHitTest.kt b/src/test/kotlin/com/criticalay/ScreenViewHitTest.kt index 71c67e9..df125dd 100644 --- a/src/test/kotlin/com/criticalay/ScreenViewHitTest.kt +++ b/src/test/kotlin/com/criticalay/ScreenViewHitTest.kt @@ -26,10 +26,11 @@ class ScreenViewHitTest { @Test fun `screen view hit maps fields to GA4 parameters`() { - val hit = ScreenViewHit("client-1", mockGa) - .screenName("HomeScreen") - .appName("MyApp") - .appVersion("1.2.3") + val hit = + ScreenViewHit("client-1", mockGa) + .screenName("HomeScreen") + .appName("MyApp") + .appVersion("1.2.3") val params = hit.event.params assertEquals("HomeScreen", params["screen_name"]) diff --git a/src/test/kotlin/com/criticalay/TimingHitTest.kt b/src/test/kotlin/com/criticalay/TimingHitTest.kt index cc728b7..a75552d 100644 --- a/src/test/kotlin/com/criticalay/TimingHitTest.kt +++ b/src/test/kotlin/com/criticalay/TimingHitTest.kt @@ -26,11 +26,12 @@ class TimingHitTest { @Test fun `timing hit maps category, name, value and label to GA4 parameters`() { - val hit = TimingHit("client-1", mockGa) - .timingCategory("api") - .timingName("fetchUser") - .timingValue(342) - .timingLabel("logged-in") + val hit = + TimingHit("client-1", mockGa) + .timingCategory("api") + .timingName("fetchUser") + .timingValue(342) + .timingLabel("logged-in") val params = hit.event.params assertEquals("api", params["event_category"]) diff --git a/src/test/kotlin/com/criticalay/internal/GaUtilsTest.kt b/src/test/kotlin/com/criticalay/internal/GaUtilsTest.kt index 7d4c931..4e4cbb4 100644 --- a/src/test/kotlin/com/criticalay/internal/GaUtilsTest.kt +++ b/src/test/kotlin/com/criticalay/internal/GaUtilsTest.kt @@ -24,7 +24,6 @@ import org.junit.jupiter.api.Test import org.junit.jupiter.api.assertThrows class GaUtilsTest { - // ---------- isBlank / isNotBlank ---------- @Test diff --git a/src/test/kotlin/com/criticalay/request/GaEventTest.kt b/src/test/kotlin/com/criticalay/request/GaEventTest.kt index c5ad18a..cda23f4 100644 --- a/src/test/kotlin/com/criticalay/request/GaEventTest.kt +++ b/src/test/kotlin/com/criticalay/request/GaEventTest.kt @@ -22,15 +22,15 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class GaEventTest { - @Test fun `param methods store values with correct types`() { - val event = GaEvent("test_event") - .param("str_key", "hello") - .param("int_key", 42) - .param("long_key", 9_000_000_000L) - .param("double_key", 3.14) - .param("bool_key", true) + val event = + GaEvent("test_event") + .param("str_key", "hello") + .param("int_key", 42) + .param("long_key", 9_000_000_000L) + .param("double_key", 3.14) + .param("bool_key", true) assertEquals("hello", event.params["str_key"]) assertEquals(42, event.params["int_key"]) @@ -41,10 +41,11 @@ class GaEventTest { @Test fun `param overwrites existing key with last value wins`() { - val event = GaEvent("test_event") - .param("key", "first") - .param("key", "second") - .param("key", 99) + val event = + GaEvent("test_event") + .param("key", "first") + .param("key", "second") + .param("key", 99) assertEquals(99, event.params["key"]) assertEquals(1, event.params.size) @@ -52,9 +53,10 @@ class GaEventTest { @Test fun `params bulk-add merges into existing params`() { - val event = GaEvent("test_event") - .param("existing", "value") - .params(mapOf("a" to 1, "b" to "two")) + val event = + GaEvent("test_event") + .param("existing", "value") + .params(mapOf("a" to 1, "b" to "two")) assertEquals("value", event.params["existing"]) assertEquals(1, event.params["a"]) @@ -64,9 +66,10 @@ class GaEventTest { @Test fun `params bulk-add overwrites existing keys`() { - val event = GaEvent("test_event") - .param("k", "old") - .params(mapOf("k" to "new")) + val event = + GaEvent("test_event") + .param("k", "old") + .params(mapOf("k" to "new")) assertEquals("new", event.params["k"]) } diff --git a/src/test/kotlin/com/criticalay/request/GaRequestTest.kt b/src/test/kotlin/com/criticalay/request/GaRequestTest.kt index fa54d64..b7663a1 100644 --- a/src/test/kotlin/com/criticalay/request/GaRequestTest.kt +++ b/src/test/kotlin/com/criticalay/request/GaRequestTest.kt @@ -22,7 +22,6 @@ import org.junit.jupiter.api.assertDoesNotThrow import org.junit.jupiter.api.assertThrows class GaRequestTest { - private fun event(name: String = "test") = GaEvent(name) @Test diff --git a/src/test/kotlin/com/criticalay/response/GaResponseTest.kt b/src/test/kotlin/com/criticalay/response/GaResponseTest.kt index afbf661..95f2e49 100644 --- a/src/test/kotlin/com/criticalay/response/GaResponseTest.kt +++ b/src/test/kotlin/com/criticalay/response/GaResponseTest.kt @@ -22,7 +22,6 @@ import org.junit.jupiter.api.Assertions.assertTrue import org.junit.jupiter.api.Test class GaResponseTest { - @Test fun `isSuccess is true for 2xx status codes`() { assertTrue(GaResponse(statusCode = 200).isSuccess) @@ -48,12 +47,14 @@ class GaResponseTest { @Test fun `isValid is false when there are validation messages`() { - val resp = GaResponse( - statusCode = 200, - validationMessages = listOf( - ValidationMessage("events[0].name", "Reserved", "NAME_RESERVED") - ), - ) + val resp = + GaResponse( + statusCode = 200, + validationMessages = + listOf( + ValidationMessage("events[0].name", "Reserved", "NAME_RESERVED"), + ), + ) assertFalse(resp.isValid) } @@ -67,11 +68,12 @@ class GaResponseTest { @Test fun `ValidationMessage exposes its fields`() { - val msg = ValidationMessage( - fieldPath = "events[0].name", - description = "Invalid", - validationCode = "BAD_NAME", - ) + val msg = + ValidationMessage( + fieldPath = "events[0].name", + description = "Invalid", + validationCode = "BAD_NAME", + ) assertEquals("events[0].name", msg.fieldPath) assertEquals("Invalid", msg.description) assertEquals("BAD_NAME", msg.validationCode) From ff7edeb45df70512df72806b11cfc66b2849051c Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Mon, 25 May 2026 04:53:44 +0530 Subject: [PATCH 2/5] chore: add monthly dependabot for gradle and github-actions --- .github/dependabot.yml | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 .github/dependabot.yml diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..b820479 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,28 @@ +version: 2 +updates: + # Gradle dependencies (build.gradle.kts) and Gradle plugins. + # Dependabot's gradle ecosystem excludes pre-release qualifiers + # (-rc, -alpha, -beta, -SNAPSHOT, -M*) by default, so only stable + # releases will be proposed. + - package-ecosystem: "gradle" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "gradle" + commit-message: + prefix: "deps" + + # GitHub Actions used in workflows (.github/workflows/*). + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "monthly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "github-actions" + commit-message: + prefix: "ci" From e547c63702146aa1c2b00e422a215126602d407f Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Mon, 25 May 2026 04:54:36 +0530 Subject: [PATCH 3/5] chore: add CI workflow running ktlint and tests on PR --- .github/workflows/ci.yml | 48 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 .github/workflows/ci.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..420840e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,48 @@ +name: CI + +on: + pull_request: + branches: [main] + push: + branches: [main] + +permissions: + contents: read + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +jobs: + build: + name: Lint and test (JDK 21) + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Validate Gradle wrapper + uses: gradle/actions/wrapper-validation@v4 + + - name: Set up Gradle + uses: gradle/actions/setup-gradle@v4 + + - name: Ktlint + run: ./gradlew ktlintCheck + + - name: Test + run: ./gradlew test + + - name: Upload test report on failure + if: failure() + uses: actions/upload-artifact@v4 + with: + name: test-report + path: build/reports/tests/test + retention-days: 7 From 059ecab9d6d581fdb564077cc9ea0a7305a717df Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Mon, 25 May 2026 04:54:46 +0530 Subject: [PATCH 4/5] chore: add monthly gradle wrapper update workflow --- .github/workflows/gradle-wrapper.yml | 34 ++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) create mode 100644 .github/workflows/gradle-wrapper.yml diff --git a/.github/workflows/gradle-wrapper.yml b/.github/workflows/gradle-wrapper.yml new file mode 100644 index 0000000..8f34bca --- /dev/null +++ b/.github/workflows/gradle-wrapper.yml @@ -0,0 +1,34 @@ +name: Update Gradle Wrapper + +on: + schedule: + # First day of every month at 06:00 UTC. + - cron: '0 6 1 * *' + workflow_dispatch: + +permissions: + contents: write + pull-requests: write + +jobs: + update-wrapper: + name: Bump Gradle wrapper if outdated + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up JDK 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: '21' + + - name: Update Gradle Wrapper + uses: gradle-update/update-gradle-wrapper-action@v2 + with: + repo-token: ${{ secrets.GITHUB_TOKEN }} + target-branch: main + labels: dependencies, gradle-wrapper + # Only propose stable Gradle releases. + release-channel: stable From 970c5979c6b57a7a6ce30768b21299d3ae600163 Mon Sep 17 00:00:00 2001 From: Ashish Yadav <48384865+criticalAY@users.noreply.github.com> Date: Mon, 25 May 2026 21:02:37 +0530 Subject: [PATCH 5/5] setup: pre commit hook --- .githooks/pre-commit | 19 +++++++++++++++++++ build.gradle.kts | 27 +++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) create mode 100755 .githooks/pre-commit diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..e0b23fc --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,19 @@ +#!/usr/bin/env sh +# Pre-commit hook: runs ktlintCheck if any Kotlin files are staged. +# Activated automatically by Gradle (sets core.hooksPath=.githooks on build). + +set -e + +staged=$(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.(kt|kts)$' || true) + +if [ -z "$staged" ]; then + exit 0 +fi + +echo "Running ktlintCheck on staged Kotlin files..." +if ! ./gradlew --quiet ktlintCheck; then + echo "" + echo "ktlint violations found." + echo "Run './gradlew ktlintFormat' to auto-fix, then re-stage and commit." + exit 1 +fi diff --git a/build.gradle.kts b/build.gradle.kts index b136dfe..4e6d92c 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -71,3 +71,30 @@ ktlint { exclude("**/build/**") } } + +val installGitHooks by tasks.registering { + description = "Configures git core.hooksPath to .githooks (enables ktlint pre-commit hook)." + group = "build setup" + onlyIf { file(".git").isDirectory && System.getenv("CI") == null } + doLast { + val current = ProcessBuilder("git", "config", "--get", "core.hooksPath") + .redirectErrorStream(true) + .start() + .let { p -> + val out = p.inputStream.bufferedReader().readText().trim() + p.waitFor() + out + } + if (current != ".githooks") { + ProcessBuilder("git", "config", "core.hooksPath", ".githooks") + .inheritIO() + .start() + .waitFor() + logger.lifecycle("Configured git core.hooksPath to .githooks (ktlint pre-commit hook active)") + } + } +} + +tasks.named("ktlintCheck") { + dependsOn(installGitHooks) +}