From 83e2e8a0eb33ea45a3077a66bb436e76b961ce01 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matu=CC=81s=CC=8C=20Tomlein?= Date: Tue, 23 May 2023 10:10:04 +0200 Subject: [PATCH 1/2] Add configuration to send requests with user ID to a Focal Meter endpoint (close #571) --- .../tracker/FocalMeterConfigurationTest.kt | 196 ++++++++++++++++++ .../snowplowanalytics/core/session/Session.kt | 3 +- .../configuration/FocalMeterConfiguration.kt | 95 +++++++++ .../snowplow/entity/ClientSessionEntity.kt | 28 +++ 4 files changed, 321 insertions(+), 1 deletion(-) create mode 100644 snowplow-tracker/src/androidTest/java/com/snowplowanalytics/snowplow/tracker/FocalMeterConfigurationTest.kt create mode 100644 snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/configuration/FocalMeterConfiguration.kt create mode 100644 snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/entity/ClientSessionEntity.kt diff --git a/snowplow-tracker/src/androidTest/java/com/snowplowanalytics/snowplow/tracker/FocalMeterConfigurationTest.kt b/snowplow-tracker/src/androidTest/java/com/snowplowanalytics/snowplow/tracker/FocalMeterConfigurationTest.kt new file mode 100644 index 000000000..e999361da --- /dev/null +++ b/snowplow-tracker/src/androidTest/java/com/snowplowanalytics/snowplow/tracker/FocalMeterConfigurationTest.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2015-2023 Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ + +package com.snowplowanalytics.snowplow.tracker + +import android.content.Context +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import com.snowplowanalytics.snowplow.Snowplow +import com.snowplowanalytics.snowplow.Snowplow.removeAllTrackers +import com.snowplowanalytics.snowplow.configuration.* +import com.snowplowanalytics.snowplow.controller.TrackerController +import com.snowplowanalytics.snowplow.event.Structured +import com.snowplowanalytics.snowplow.network.HttpMethod +import okhttp3.mockwebserver.MockResponse +import okhttp3.mockwebserver.MockWebServer +import org.junit.After +import org.junit.Assert +import org.junit.Test +import org.junit.runner.RunWith +import java.util.* + +@RunWith(AndroidJUnit4::class) +class FocalMeterConfigurationTest { + + @After + fun tearDown() { + removeAllTrackers() + } + + // --- TESTS + @Test + fun logsSuccessfulRequest() { + withMockServer(200) { mockServer, endpoint -> + val focalMeter = FocalMeterConfiguration(endpoint) + val debugs = mutableListOf() + val loggerDelegate = createLoggerDelegate(debugs = debugs) + val trackerConfig = TrackerConfiguration(appId = "app-id") + trackerConfig.logLevel(LogLevel.DEBUG) + trackerConfig.loggerDelegate(loggerDelegate) + + val tracker = createTracker(listOf(focalMeter, trackerConfig)) + tracker.track(Structured("cat", "act")) + tracker.track(Structured("cat", "act")) + tracker.track(Structured("cat", "act")) + + Thread.sleep(500) + Assert.assertEquals( + 1, + debugs.filter { + it.contains("Request to Kantar endpoint sent with user ID: ${tracker.session?.userId}") + }.size + ) + } + } + + @Test + fun logsSuccessfulRequestWithProcessedUserId() { + withMockServer(200) { mockServer, endpoint -> + val focalMeter = FocalMeterConfiguration( + kantarEndpoint = endpoint, + processUserId = { userId -> "processed-" + userId } + ) + val debugs = mutableListOf() + val loggerDelegate = createLoggerDelegate(debugs = debugs) + val trackerConfig = TrackerConfiguration(appId = "app-id") + trackerConfig.logLevel(LogLevel.DEBUG) + trackerConfig.loggerDelegate(loggerDelegate) + + val tracker = createTracker(listOf(focalMeter, trackerConfig)) + tracker.track(Structured("cat", "act")) + + Thread.sleep(500) + Assert.assertEquals( + 1, + debugs.filter { + it.contains("Request to Kantar endpoint sent with user ID: processed-${tracker.session?.userId}") + }.size + ) + } + } + + @Test + fun makesAnotherRequestWhenUserIdChanges() { + withMockServer(200) { mockServer, endpoint -> + val focalMeter = FocalMeterConfiguration(endpoint) + val debugs = mutableListOf() + val loggerDelegate = createLoggerDelegate(debugs = debugs) + val trackerConfig = TrackerConfiguration(appId = "app-id") + trackerConfig.logLevel(LogLevel.DEBUG) + trackerConfig.loggerDelegate(loggerDelegate) + + val tracker = createTracker(listOf(focalMeter, trackerConfig)) + tracker.track(Structured("cat", "act")) + val firstUserId = tracker.session?.userId + tracker.session?.startNewSession() + tracker.track(Structured("cat", "act")) + val secondUserId = tracker.session?.userId + + Thread.sleep(500) + Assert.assertEquals( + 1, + debugs.filter { + it.contains("Request to Kantar endpoint sent with user ID: ${firstUserId}") + }.size + ) + Assert.assertEquals( + 1, + debugs.filter { + it.contains("Request to Kantar endpoint sent with user ID: ${secondUserId}") + }.size + ) + } + } + + @Test + fun logsFailedRequest() { + withMockServer(500) { mockServer, endpoint -> + val focalMeter = FocalMeterConfiguration(endpoint) + val errors = mutableListOf() + val loggerDelegate = createLoggerDelegate(errors = errors) + val trackerConfig = TrackerConfiguration(appId = "app-id") + trackerConfig.logLevel(LogLevel.DEBUG) + trackerConfig.loggerDelegate(loggerDelegate) + + val tracker = createTracker(listOf(focalMeter, trackerConfig)) + tracker.track(Structured("cat", "act")) + + Thread.sleep(500) + Assert.assertEquals( + 1, + errors.filter { + it.contains("Request to Kantar endpoint failed with code: 500") + }.size + ) + } + } + + // --- PRIVATE + private val context: Context + get() = InstrumentationRegistry.getInstrumentation().targetContext + + private fun createTracker(configurations: List): TrackerController { + val networkConfig = NetworkConfiguration(MockNetworkConnection(HttpMethod.POST, 200)) + return Snowplow.createTracker( + context, + namespace = "ns" + Math.random().toString(), + network = networkConfig, + configurations = configurations.toTypedArray() + ) + } + + private fun withMockServer(responseCode: Int, callback: (MockWebServer, String) -> Unit) { + val mockServer = MockWebServer() + mockServer.start() + val mockResponse = MockResponse() + .setResponseCode(responseCode) + .setHeader("Content-Type", "application/json") + .setBody("") + mockServer.enqueue(mockResponse) + val endpoint = String.format("http://%s:%d", mockServer.hostName, mockServer.port) + callback(mockServer, endpoint) + mockServer.shutdown() + } + + private fun createLoggerDelegate( + errors: MutableList = mutableListOf(), + debugs: MutableList = mutableListOf(), + verboses: MutableList = mutableListOf() + ): LoggerDelegate { + return object : LoggerDelegate { + + override fun error(tag: String, msg: String) { + errors.add(msg) + } + + override fun debug(tag: String, msg: String) { + debugs.add(msg) + } + + override fun verbose(tag: String, msg: String) { + verboses.add(msg) + } + } + } +} diff --git a/snowplow-tracker/src/main/java/com/snowplowanalytics/core/session/Session.kt b/snowplow-tracker/src/main/java/com/snowplowanalytics/core/session/Session.kt index 5e00358d3..6eb737c2b 100644 --- a/snowplow-tracker/src/main/java/com/snowplowanalytics/core/session/Session.kt +++ b/snowplow-tracker/src/main/java/com/snowplowanalytics/core/session/Session.kt @@ -21,6 +21,7 @@ import com.snowplowanalytics.core.constants.Parameters import com.snowplowanalytics.core.constants.TrackerConstants import com.snowplowanalytics.core.tracker.Logger import com.snowplowanalytics.core.utils.Util +import com.snowplowanalytics.snowplow.entity.ClientSessionEntity import com.snowplowanalytics.snowplow.payload.SelfDescribingJson import com.snowplowanalytics.snowplow.tracker.SessionState import com.snowplowanalytics.snowplow.tracker.SessionState.Companion.build @@ -154,7 +155,7 @@ class Session @SuppressLint("ApplySharedPref") constructor( "00000000-0000-0000-0000-000000000000" sessionCopy[Parameters.SESSION_PREVIOUS_ID] = null } - return SelfDescribingJson(TrackerConstants.SESSION_SCHEMA, sessionCopy) + return ClientSessionEntity(sessionCopy) } private fun shouldUpdateSession(): Boolean { diff --git a/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/configuration/FocalMeterConfiguration.kt b/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/configuration/FocalMeterConfiguration.kt new file mode 100644 index 000000000..fe533762b --- /dev/null +++ b/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/configuration/FocalMeterConfiguration.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2015-2023 Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ + +package com.snowplowanalytics.snowplow.configuration + +import android.net.Uri +import com.snowplowanalytics.core.tracker.Logger +import com.snowplowanalytics.snowplow.entity.ClientSessionEntity +import okhttp3.OkHttpClient +import okhttp3.Request +import java.io.IOException +import java.util.function.Function + +/** + * This configuration tells the tracker to send requests with the user ID in session context entity + * to a Kantar endpoint used with FocalMeter. + * The request is made when the first event with a new user ID is tracked. + * The requests are only made if session context is enabled (default). + * @param kantarEndpoint The Kantar URI endpoint including the HTTP protocol to send the requests to. + * @param processUserId Callback to process user ID before sending it in a request. This may be used to apply hashing to the value. + */ +class FocalMeterConfiguration( + val kantarEndpoint: String, + val processUserId: Function? = null, +) : Configuration, PluginAfterTrackCallable, PluginIdentifiable { + private val TAG = FocalMeterConfiguration::class.java.simpleName + + private var lastUserId: String? = null + + override val identifier: String + get() = "KantarFocalMeter" + + override val afterTrackConfiguration: PluginAfterTrackConfiguration? + get() = PluginAfterTrackConfiguration { event -> + val session = event.entities.find { it is ClientSessionEntity } as? ClientSessionEntity + session?.userId?.let { newUserId -> + if (shouldUpdate(newUserId)) { + val processedUserId = processUserId?.apply(newUserId) ?: newUserId + makeRequest(processedUserId) + } + } + } + + private fun shouldUpdate(userId: String): Boolean { + synchronized(this) { + if (lastUserId == null || lastUserId != userId) { + lastUserId = userId + return true + } + return false + } + } + + private fun makeRequest(userId: String) { + val uriBuilder = Uri.parse(kantarEndpoint).buildUpon() + uriBuilder.appendQueryParameter("vendor", "snowplow") + uriBuilder.appendQueryParameter("cs_fpid", userId) + uriBuilder.appendQueryParameter("c12", "not_set") + + val client = OkHttpClient.Builder() + .connectTimeout(15, java.util.concurrent.TimeUnit.SECONDS) + .readTimeout(15, java.util.concurrent.TimeUnit.SECONDS) + .build() + + val request = Request.Builder() + .url(uriBuilder.build().toString()) + .build() + + try { + val response = client.newCall(request).execute() + if (response.isSuccessful) { + Logger.d(TAG, "Request to Kantar endpoint sent with user ID: $userId") + } else { + Logger.e(TAG, "Request to Kantar endpoint failed with code: ${response.code}") + } + } catch (e: IOException) { + Logger.e(TAG, "Request to Kantar endpoint failed with exception: ${e.message}") + } + } + + override fun copy(): Configuration { + return FocalMeterConfiguration(kantarEndpoint = kantarEndpoint) + } + +} diff --git a/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/entity/ClientSessionEntity.kt b/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/entity/ClientSessionEntity.kt new file mode 100644 index 000000000..4d1a24356 --- /dev/null +++ b/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/entity/ClientSessionEntity.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2015-2023 Snowplow Analytics Ltd. All rights reserved. + * + * This program is licensed to you under the Apache License Version 2.0, + * and you may not use this file except in compliance with the Apache License Version 2.0. + * You may obtain a copy of the Apache License Version 2.0 at http://www.apache.org/licenses/LICENSE-2.0. + * + * Unless required by applicable law or agreed to in writing, + * software distributed under the Apache License Version 2.0 is distributed on an + * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the Apache License Version 2.0 for the specific language governing permissions and limitations there under. + */ + +package com.snowplowanalytics.snowplow.entity + +import com.snowplowanalytics.core.constants.Parameters +import com.snowplowanalytics.core.constants.TrackerConstants +import com.snowplowanalytics.snowplow.payload.SelfDescribingJson + +/** + * Used to represent session information. + */ +class ClientSessionEntity(private val values: Map) : + SelfDescribingJson(TrackerConstants.SESSION_SCHEMA, values) { + + val userId: String? + get() = values[Parameters.SESSION_USER_ID] as String? +} From 495a68e0091c74d7b725780938bcc966e2c7995b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Matu=CC=81s=CC=8C=20Tomlein?= Date: Thu, 12 Oct 2023 15:01:31 +0200 Subject: [PATCH 2/2] Prepare for 5.6.0 release --- CHANGELOG | 4 ++++ VERSION | 2 +- build.gradle | 2 +- gradle.properties | 2 +- 4 files changed, 7 insertions(+), 3 deletions(-) diff --git a/CHANGELOG b/CHANGELOG index d9970d272..f6566f555 100644 --- a/CHANGELOG +++ b/CHANGELOG @@ -1,3 +1,7 @@ +Version 5.6.0 (2023-10-12) +-------------------------- +Add configuration to send requests with user ID to a Focal Meter endpoint (#571) + Version 5.5.0 (2023-10-02) -------------------------- Add option to disable retrying any failed requests (#641) diff --git a/VERSION b/VERSION index d50359de1..1bc788d3b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -5.5.0 +5.6.0 diff --git a/build.gradle b/build.gradle index e67495cc5..e8db8ba6d 100644 --- a/build.gradle +++ b/build.gradle @@ -22,7 +22,7 @@ plugins { subprojects { group = 'com.snowplowanalytics' - version = '5.5.0' + version = '5.6.0' repositories { google() maven { diff --git a/gradle.properties b/gradle.properties index 79b3d21c1..80dd62f3a 100644 --- a/gradle.properties +++ b/gradle.properties @@ -31,7 +31,7 @@ systemProp.org.gradle.internal.http.socketTimeout=120000 SONATYPE_STAGING_PROFILE=comsnowplowanalytics GROUP=com.snowplowanalytics POM_ARTIFACT_ID=snowplow-android-tracker -VERSION_NAME=5.5.0 +VERSION_NAME=5.6.0 POM_NAME=snowplow-android-tracker POM_PACKAGING=aar