Skip to content

Commit

Permalink
Add API to decorate link with user/session info (close #639)
Browse files Browse the repository at this point in the history
  • Loading branch information
greg-el authored and matus-tomlein committed Oct 5, 2023
1 parent 0605fd0 commit 8fe5da9
Show file tree
Hide file tree
Showing 5 changed files with 327 additions and 0 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,168 @@
package com.snowplowanalytics.snowplow.tracker


import android.net.Uri
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.snowplowanalytics.core.utils.Util.urlSafeBase64Encode
import com.snowplowanalytics.snowplow.Snowplow
import com.snowplowanalytics.snowplow.configuration.NetworkConfiguration
import com.snowplowanalytics.snowplow.configuration.SessionConfiguration
import com.snowplowanalytics.snowplow.configuration.SubjectConfiguration
import com.snowplowanalytics.snowplow.configuration.TrackerConfiguration
import com.snowplowanalytics.snowplow.controller.SessionController
import com.snowplowanalytics.snowplow.controller.TrackerController
import com.snowplowanalytics.snowplow.event.ScreenView
import com.snowplowanalytics.snowplow.network.HttpMethod
import com.snowplowanalytics.snowplow.util.TimeMeasure
import org.junit.Assert
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import java.util.concurrent.TimeUnit


@RunWith(AndroidJUnit4::class)
class LinkDecoratorTest {
private lateinit var tracker: TrackerController
private lateinit var session: SessionController
private lateinit var userId: String
private lateinit var appId: String
private val subjectUserId = "subjectUserId"
private val subjectUserIdEncoded = urlSafeBase64Encode(subjectUserId)
private val testLink = Uri.parse("http://example.com")
private val epoch = "\\d{13}"

private fun matches(pattern: String, result: Uri) {
val regex = Regex("^${pattern.replace(".", "\\.").replace("?", "\\?")}$")
Assert.assertTrue(
"$result\ndoes not match expected: $pattern", regex.matches(result.toString())
)
}


@Before
fun before() {
tracker = getTracker()
session = tracker.session!!
userId = session.userId
appId = urlSafeBase64Encode(tracker.appId)
}

@Test
fun testWithoutSession() {
val tracker = getTrackerNoSession()
val result = tracker.decorateLink(testLink)
Assert.assertEquals(null, result)
}

@Test
fun testDecorateUriWithExistingSpParam() {
tracker.track(ScreenView("test"))

val pattern = "http://example.com?_sp=$userId.$epoch.${session.sessionId}..$appId"
val result =
tracker.decorateLink(testLink.buildUpon().appendQueryParameter("_sp", "test").build())

matches(pattern, result!!)
}

@Test
fun testDecorateUriWithOtherParam() {
tracker.track(ScreenView("test"))

val pattern = "http://example.com?a=b&_sp=$userId.$epoch.${session.sessionId}..$appId$"
val result =
tracker.decorateLink(testLink.buildUpon().appendQueryParameter("a", "b").build())

matches(pattern, result!!)
}

@Test
fun testDecorateUriWithParameters() {
tracker.track(ScreenView("test"))

val sessionId = session.sessionId
val decorate = { c: CrossDeviceParameterConfiguration -> tracker.decorateLink(testLink, c)!! }

matches(
"http://example.com?_sp=$userId.$epoch.$sessionId",
decorate(CrossDeviceParameterConfiguration(sourceId = false))
)

matches(
"http://example.com?_sp=$userId.$epoch.$sessionId..$appId",
decorate(CrossDeviceParameterConfiguration())
)

matches(
"http://example.com?_sp=$userId.$epoch.$sessionId..$appId.mob",
decorate(CrossDeviceParameterConfiguration(sourcePlatform = true))
)

matches(
"http://example.com?_sp=$userId.$epoch.$sessionId.$subjectUserIdEncoded.$appId.mob",
decorate(CrossDeviceParameterConfiguration(sourcePlatform = true, subjectUserId = true))
)

matches(
"http://example.com?_sp=$userId.$epoch.$sessionId...mob",
decorate(CrossDeviceParameterConfiguration(sourceId = false, sourcePlatform = true))
)

matches(
"http://example.com?_sp=$userId.$epoch..$subjectUserIdEncoded.$appId",
decorate(CrossDeviceParameterConfiguration(sessionId = false, subjectUserId = true))
)

matches(
"http://example.com?_sp=$userId.$epoch..$subjectUserIdEncoded.$appId",
decorate(CrossDeviceParameterConfiguration(sessionId = false, subjectUserId = true))
)


matches(
"http://example.com?_sp=$userId.$epoch",
decorate(CrossDeviceParameterConfiguration(sourceId = false, sessionId = false))
)
}

private fun getTracker(): TrackerController {
val context = InstrumentationRegistry.getInstrumentation().targetContext

val networkConfiguration = NetworkConfiguration(MockNetworkConnection(HttpMethod.POST, 200))

val trackerConfiguration = TrackerConfiguration("decoratorTest").sessionContext(true)

val subjectConfig = SubjectConfiguration().userId(subjectUserId)

val sessionConfiguration = SessionConfiguration(
TimeMeasure(6, TimeUnit.SECONDS),
TimeMeasure(30, TimeUnit.SECONDS),
)

return Snowplow.createTracker(
context,
"namespace" + Math.random(),
networkConfiguration,
trackerConfiguration,
sessionConfiguration,
subjectConfig
)
}

private fun getTrackerNoSession(): TrackerController {
val context = InstrumentationRegistry.getInstrumentation().targetContext

val networkConfiguration = NetworkConfiguration(MockNetworkConnection(HttpMethod.POST, 200))

val trackerConfiguration = TrackerConfiguration("decoratorTest").sessionContext(false)

return Snowplow.createTracker(
context,
"namespace" + Math.random(),
networkConfiguration,
trackerConfiguration,
)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,15 +12,18 @@
*/
package com.snowplowanalytics.core.tracker

import android.net.Uri
import androidx.annotation.RestrictTo
import com.snowplowanalytics.core.Controller
import com.snowplowanalytics.core.ecommerce.EcommerceControllerImpl
import com.snowplowanalytics.core.session.SessionControllerImpl
import com.snowplowanalytics.core.utils.Util.urlSafeBase64Encode
import com.snowplowanalytics.snowplow.configuration.TrackerConfiguration
import com.snowplowanalytics.snowplow.controller.*
import com.snowplowanalytics.snowplow.event.Event
import com.snowplowanalytics.snowplow.media.controller.MediaController
import com.snowplowanalytics.snowplow.tracker.BuildConfig
import com.snowplowanalytics.snowplow.tracker.CrossDeviceParameterConfiguration
import com.snowplowanalytics.snowplow.tracker.DevicePlatform
import com.snowplowanalytics.snowplow.tracker.LogLevel
import com.snowplowanalytics.snowplow.tracker.LoggerDelegate
Expand Down Expand Up @@ -70,6 +73,89 @@ class TrackerControllerImpl // Constructors
return tracker.track(event)
}

private fun decorateLinkErrorTemplate(extendedParameterName: String): String {
return "$extendedParameterName has been requested in CrossDeviceParameterConfiguration, but it is not set."
}

override fun decorateLink(
uri: Uri,
extendedParameters: CrossDeviceParameterConfiguration?
): Uri? {
// UserId is a required parameter of `_sp`
val userId = this.session?.userId
if (userId == null) {
Logger.track(TAG, "$uri could not be decorated as session.userId is null")
return null
}

val extendedParameters = extendedParameters ?: CrossDeviceParameterConfiguration()

val sessionId = if (extendedParameters.sessionId) {
this.session?.sessionId ?: ""
} else {
""
}
if (extendedParameters.sessionId && sessionId.isEmpty()) {
Logger.d(
TAG,
"${decorateLinkErrorTemplate("sessionId")} Ensure an event has been tracked to generate a session before calling this method."
)
}

val sourceId = if (extendedParameters.sourceId) {
this.appId
} else {
""
}
val sourcePlatform = if (extendedParameters.sourcePlatform) {
this.devicePlatform.value
} else {
""
}

val subjectUserId = if (extendedParameters.subjectUserId) {
this.subject.userId ?: ""
} else {
""
}
if (extendedParameters.subjectUserId && subjectUserId.isEmpty()) {
Logger.d(
TAG,
"${decorateLinkErrorTemplate("subjectUserId")} Ensure SubjectConfiguration.userId has been set on your tracker."
)
}

val reason = extendedParameters.reason ?: ""

// Create our list of values in the required order
val spParameters = listOf(
userId,
System.currentTimeMillis(),
sessionId,
urlSafeBase64Encode(subjectUserId),
urlSafeBase64Encode(sourceId),
sourcePlatform,
urlSafeBase64Encode(reason)
).joinToString(".").trimEnd('.')

// Remove any existing `_sp` param if present
val builder = uri.buildUpon()
if (!uri.getQueryParameter(crossDeviceQueryParameterKey).isNullOrBlank()) {
builder.clearQuery()
uri.queryParameterNames.forEach {
if (it != crossDeviceQueryParameterKey) builder.appendQueryParameter(
it,
uri.getQueryParameter(it)
)
}
}

return builder.appendQueryParameter(
crossDeviceQueryParameterKey,
spParameters
).build()
}

override val version: String
get() = BuildConfig.TRACKER_LABEL
override val isTracking: Boolean
Expand Down Expand Up @@ -219,6 +305,8 @@ class TrackerControllerImpl // Constructors
private val dirtyConfig: TrackerConfiguration
get() = serviceProvider.trackerConfiguration

private val crossDeviceQueryParameterKey = "_sp"

companion object {
private val TAG = TrackerControllerImpl::class.java.simpleName
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,17 @@ object Util {
return Base64.encodeToString(string.toByteArray(), Base64.NO_WRAP)
}

/**
* Encodes a string into URL-safe Base64.
*
* @param string the string to encode
* @return a Base64 encoded string
*/
@JvmStatic
fun urlSafeBase64Encode(string: String): String {
return Base64.encodeToString(string.toByteArray(), Base64.URL_SAFE or Base64.NO_PADDING or Base64.NO_WRAP)
}

/**
* Generates a random UUID for each event.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
*/
package com.snowplowanalytics.snowplow.controller

import android.net.Uri
import com.snowplowanalytics.snowplow.tracker.CrossDeviceParameterConfiguration
import com.snowplowanalytics.core.tracker.TrackerConfigurationInterface
import com.snowplowanalytics.snowplow.ecommerce.EcommerceController
import com.snowplowanalytics.snowplow.event.Event
Expand Down Expand Up @@ -116,4 +118,23 @@ interface TrackerController : TrackerConfigurationInterface {
* The tracker will start tracking again.
*/
fun resume()

/**
* Adds user and session information to a URI.
*
* For example, calling decorateLink on `appSchema://path/to/page` with all extended parameters enabled will return:
*
* `appSchema://path/to/page?_sp=domainUserId.timestamp.sessionId.subjectUserId.sourceId.platform.reason`
*
* @param uri The URI to add the query string to
* @param extendedParameters Any optional parameters to include in the query string.
*
* @return Optional URL
* - null if [SessionController.userId] is null from `sessionContext(false)` being passed in [TrackerConfiguration]
* - otherwise, decorated URL
*/
fun decorateLink(
uri: Uri,
extendedParameters: CrossDeviceParameterConfiguration? = null
): Uri?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/*
* 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 com.snowplowanalytics.snowplow.controller.SessionController
import com.snowplowanalytics.snowplow.controller.TrackerController
import com.snowplowanalytics.snowplow.controller.SubjectController

/**
* Configuration object for [TrackerController.decorateLink]
*
* Enabled properties will be included when decorating a URI using `decorateLink`
*/
data class CrossDeviceParameterConfiguration(
/** Whether to include the value of [SessionController.sessionId] when decorating a link (enabled by default) */
val sessionId: Boolean = true,

/** Whether to include the value of [SubjectController.userId] when decorating a link */
val subjectUserId: Boolean = false,

/** Whether to include the value of [TrackerController.appId] when decorating a link (enabled by default) */
val sourceId: Boolean = true,

/** Whether to include the value of [TrackerController.devicePlatform] when decorating a link */
val sourcePlatform: Boolean = false,

/** Optional identifier/information for cross-navigation */
val reason: String? = null
)

0 comments on commit 8fe5da9

Please sign in to comment.