Skip to content

Commit

Permalink
Add configuration to send requests with user ID to a Focal Meter endp…
Browse files Browse the repository at this point in the history
…oint (close #571)
  • Loading branch information
matus-tomlein committed Jul 13, 2023
1 parent 0ef6920 commit 19a9df8
Show file tree
Hide file tree
Showing 4 changed files with 321 additions and 1 deletion.
Original file line number Diff line number Diff line change
@@ -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<String>()
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<String>()
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<String>()
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<String>()
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<Configuration>): 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<String> = mutableListOf(),
debugs: MutableList<String> = mutableListOf(),
verboses: MutableList<String> = 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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, String>? = 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)
}

}
Original file line number Diff line number Diff line change
@@ -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<String, Any?>) :
SelfDescribingJson(TrackerConstants.SESSION_SCHEMA, values) {

val userId: String?
get() = values[Parameters.SESSION_USER_ID] as String?
}

0 comments on commit 19a9df8

Please sign in to comment.