From 2c6df7ea6e56c8ddb6261902785798d0cd89329e Mon Sep 17 00:00:00 2001 From: Greg Leonard <45019882+greg-el@users.noreply.github.com> Date: Tue, 5 Sep 2023 12:10:01 +0100 Subject: [PATCH] Add API to decorate link with user/session info (close #639) --- .../snowplow/tracker/LinkDecoratorTest.kt | 168 ++++++++++++++++++ .../core/tracker/TrackerControllerImpl.kt | 88 +++++++++ .../com/snowplowanalytics/core/utils/Util.kt | 11 ++ .../snowplow/controller/TrackerController.kt | 21 +++ .../CrossDeviceParameterConfiguration.kt | 39 ++++ 5 files changed, 327 insertions(+) create mode 100644 snowplow-tracker/src/androidTest/java/com/snowplowanalytics/snowplow/tracker/LinkDecoratorTest.kt create mode 100644 snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/tracker/CrossDeviceParameterConfiguration.kt diff --git a/snowplow-tracker/src/androidTest/java/com/snowplowanalytics/snowplow/tracker/LinkDecoratorTest.kt b/snowplow-tracker/src/androidTest/java/com/snowplowanalytics/snowplow/tracker/LinkDecoratorTest.kt new file mode 100644 index 000000000..1c6aed3aa --- /dev/null +++ b/snowplow-tracker/src/androidTest/java/com/snowplowanalytics/snowplow/tracker/LinkDecoratorTest.kt @@ -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, + ) + } +} diff --git a/snowplow-tracker/src/main/java/com/snowplowanalytics/core/tracker/TrackerControllerImpl.kt b/snowplow-tracker/src/main/java/com/snowplowanalytics/core/tracker/TrackerControllerImpl.kt index bd50bb7bf..43595821c 100644 --- a/snowplow-tracker/src/main/java/com/snowplowanalytics/core/tracker/TrackerControllerImpl.kt +++ b/snowplow-tracker/src/main/java/com/snowplowanalytics/core/tracker/TrackerControllerImpl.kt @@ -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 @@ -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 @@ -226,6 +312,8 @@ class TrackerControllerImpl // Constructors private val dirtyConfig: TrackerConfiguration get() = serviceProvider.trackerConfiguration + private val crossDeviceQueryParameterKey = "_sp" + companion object { private val TAG = TrackerControllerImpl::class.java.simpleName } diff --git a/snowplow-tracker/src/main/java/com/snowplowanalytics/core/utils/Util.kt b/snowplow-tracker/src/main/java/com/snowplowanalytics/core/utils/Util.kt index e812b038d..85e020ca7 100755 --- a/snowplow-tracker/src/main/java/com/snowplowanalytics/core/utils/Util.kt +++ b/snowplow-tracker/src/main/java/com/snowplowanalytics/core/utils/Util.kt @@ -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. * diff --git a/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/controller/TrackerController.kt b/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/controller/TrackerController.kt index f32a80b89..b4d697c59 100644 --- a/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/controller/TrackerController.kt +++ b/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/controller/TrackerController.kt @@ -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 @@ -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? } diff --git a/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/tracker/CrossDeviceParameterConfiguration.kt b/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/tracker/CrossDeviceParameterConfiguration.kt new file mode 100644 index 000000000..de7df2866 --- /dev/null +++ b/snowplow-tracker/src/main/java/com/snowplowanalytics/snowplow/tracker/CrossDeviceParameterConfiguration.kt @@ -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 +)