Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -634,3 +634,18 @@ ConstructorIo.trackItemDetailLoaded("Pencil", "123", "234")
// Track when a product is clicked. Should be used when a clicked product is not part of search/browse/recommendation experiences. (itemName, customerId, variationId?, sectionName?)
ConstructorIo.trackGenericResultClick("Pencil", "123", "234")
```

### Media Impression Events

```kotlin
// Track when a media placement is viewed
ConstructorIo.trackMediaImpressionView(
bannerAdId = "abc123",
placementId = "home"
)

// Track when a media placement is clicked
ConstructorIo.trackMediaImpressionClick(
bannerAdId = "abc123",
placementId = "home"
)
59 changes: 59 additions & 0 deletions library/src/main/java/io/constructor/core/ConstructorIo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import io.constructor.data.model.recommendations.RecommendationsResponse
import io.constructor.data.model.search.*
import io.constructor.data.model.tracking.GenericResultClickRequestBody
import io.constructor.data.model.tracking.ItemDetailLoadRequestBody
import io.constructor.data.model.tracking.MediaImpressionClickRequestBody
import io.constructor.data.model.tracking.MediaImpressionViewRequestBody
import io.constructor.injection.component.AppComponent
import io.constructor.injection.component.DaggerAppComponent
import io.constructor.injection.module.AppModule
Expand Down Expand Up @@ -120,6 +122,7 @@ object ConstructorIo {
preferenceHelper.apiKey = constructorIoConfig.apiKey
preferenceHelper.serviceUrl = constructorIoConfig.serviceUrl
preferenceHelper.quizzesServiceUrl = constructorIoConfig.quizzesServiceUrl
preferenceHelper.mediaServiceUrl = constructorIoConfig.mediaServiceUrl
preferenceHelper.port = constructorIoConfig.servicePort
preferenceHelper.scheme = constructorIoConfig.serviceScheme
preferenceHelper.defaultItemSection = constructorIoConfig.defaultItemSection
Expand Down Expand Up @@ -1894,6 +1897,26 @@ object ConstructorIo {
}))
}

/**
* Tracks media impression view events.
*/
fun trackMediaImpressionView(bannerAdId: String, placementId: String) {
val completable = trackMediaImpressionViewInternal(bannerAdId, placementId)
disposable.add(completable.subscribeOn(Schedulers.io()).subscribe({}, { t ->
e("Media Impression View error: ${t.message}")
}))
}

/**
* Tracks media impression click events.
*/
fun trackMediaImpressionClick(bannerAdId: String, placementId: String) {
val completable = trackMediaImpressionClickInternal(bannerAdId, placementId)
disposable.add(completable.subscribeOn(Schedulers.io()).subscribe({}, { t ->
e("Media Impression Click error: ${t.message}")
}))
}

internal fun trackItemDetailLoadedInternal(itemName: String, customerId: String, variationId: String? = null, sectionName: String? = null, url: String = "Not Available", analyticsTags: Map<String, String>? = null): Completable {
preferenceHelper.getSessionId(sessionIncrementHandler)
val section = sectionName ?: preferenceHelper.defaultItemSection
Expand All @@ -1920,6 +1943,42 @@ object ConstructorIo {
)
}

internal fun trackMediaImpressionViewInternal(bannerAdId: String, placementId: String): Completable {
preferenceHelper.getSessionId(sessionIncrementHandler)
val requestBody = MediaImpressionViewRequestBody(
bannerAdId,
placementId,
true,
BuildConfig.CLIENT_VERSION,
preferenceHelper.id,
preferenceHelper.getSessionId(),
preferenceHelper.apiKey,
configMemoryHolder.userId,
configMemoryHolder.segments,
System.currentTimeMillis()
)

return dataManager.trackMediaImpressionView(preferenceHelper, requestBody)
}

internal fun trackMediaImpressionClickInternal(bannerAdId: String, placementId: String): Completable {
preferenceHelper.getSessionId(sessionIncrementHandler)
val requestBody = MediaImpressionClickRequestBody(
bannerAdId,
placementId,
true,
BuildConfig.CLIENT_VERSION,
preferenceHelper.id,
preferenceHelper.getSessionId(),
preferenceHelper.apiKey,
configMemoryHolder.userId,
configMemoryHolder.segments,
System.currentTimeMillis()
)

return dataManager.trackMediaImpressionClick(preferenceHelper, requestBody)
}

/**
* Tracks generic result click events.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import io.constructor.BuildConfig
* @property servicePort The port to use (for testing purposes only, defaults to 443)
* @property serviceScheme The scheme to use (for testing purposes only, defaults to HTTPS)
* @property defaultAnalyticsTags Additional analytics tags to pass. Will be merged with analytics tags passed on the request level
* @property mediaServiceUrl Constructor.io media service URL (defaults to "behavior.media-cnstrc.com")
*/
data class ConstructorIoConfig(
val apiKey: String,
Expand All @@ -24,5 +25,6 @@ data class ConstructorIoConfig(
val defaultItemSection: String = BuildConfig.DEFAULT_ITEM_SECTION,
val servicePort: Int = BuildConfig.SERVICE_PORT,
val serviceScheme: String = BuildConfig.SERVICE_SCHEME,
val defaultAnalyticsTags: Map<String, String> = emptyMap()
val defaultAnalyticsTags: Map<String, String> = emptyMap(),
val mediaServiceUrl: String = "behavior.media-cnstrc.com"
)
27 changes: 27 additions & 0 deletions library/src/main/java/io/constructor/data/DataManager.kt
Original file line number Diff line number Diff line change
Expand Up @@ -13,11 +13,14 @@ import io.constructor.data.model.recommendations.RecommendationResultViewRequest
import io.constructor.data.model.recommendations.RecommendationsResponse
import io.constructor.data.model.search.*
import io.constructor.data.model.tracking.GenericResultClickRequestBody
import io.constructor.data.model.tracking.MediaImpressionClickRequestBody
import io.constructor.data.model.tracking.MediaImpressionViewRequestBody
import io.constructor.data.remote.ApiPaths
import io.constructor.data.remote.ConstructorApi
import io.constructor.injection.ConstructorSdk
import io.reactivex.Completable
import io.reactivex.Observable
import okhttp3.HttpUrl
import javax.inject.Inject
import javax.inject.Singleton

Expand Down Expand Up @@ -312,6 +315,30 @@ constructor(private val constructorApi: ConstructorApi, @ConstructorSdk private
return constructorApi.trackQuizConversion(quizConversionRequestBody, params.toMap())
}

fun trackMediaImpressionView(preferencesHelper: PreferencesHelper, mediaImpressionViewRequestBody: MediaImpressionViewRequestBody): Completable {
val url = buildMediaUrl(preferencesHelper, ApiPaths.URL_MEDIA_IMPRESSION_VIEW_EVENT)
return constructorApi.trackMediaImpressionView(url, mediaImpressionViewRequestBody)
}

fun trackMediaImpressionClick(preferencesHelper: PreferencesHelper, mediaImpressionClickRequestBody: MediaImpressionClickRequestBody): Completable {
val url = buildMediaUrl(preferencesHelper, ApiPaths.URL_MEDIA_IMPRESSION_CLICK_EVENT)
return constructorApi.trackMediaImpressionClick(url, mediaImpressionClickRequestBody)
}

private fun buildMediaUrl(preferencesHelper: PreferencesHelper, path: String): String {
val cleanedPathSegments = path.split("/").filter { it.isNotBlank() }
val scheme = preferencesHelper.scheme?.takeIf { it.isNotBlank() } ?: "https"
val host = preferencesHelper.mediaServiceUrl?.takeIf { it.isNotBlank() }
?: preferencesHelper.serviceUrl?.takeIf { it.isNotBlank() }
?: throw IllegalStateException("Media service host is not configured")
val builder = HttpUrl.Builder()
.scheme(scheme)
.host(host)
.port(preferencesHelper.port)
cleanedPathSegments.forEach { builder.addPathSegment(it) }
return builder.build().toString()
}

fun getQuizNextQuestion(quizId: String, encodedParams: Array<Pair<String, String>> = arrayOf(), preferencesHelper: PreferencesHelper): Observable<ConstructorData<QuizQuestionResponse>> {
val url = "${preferencesHelper.scheme}://${preferencesHelper.quizzesServiceUrl}/${ApiPaths.URL_QUIZ_NEXT_QUESTION.format(quizId)}${getAdditionalParamsQueryString(encodedParams)}"
return constructorApi.getQuizNextQuestion(url).map {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,13 +46,16 @@ class RequestInterceptor(

override fun intercept(chain: Interceptor.Chain): Response {
val ignoreDtPaths = listOf(ApiPaths.URL_BROWSE_GROUPS, ApiPaths.URL_BROWSE_FACETS, ApiPaths.URL_BROWSE_FACET_OPTIONS);
val behavioralEndpointPaths = listOf(ApiPaths.URL_BEHAVIORAL_V1_PREFIX.toRegex(), ApiPaths.URL_BEHAVIORAL_V2_PREFIX.toRegex(), ApiPaths.URL_BEHAVIORAL_SEARCH_REGEX.toRegex() )
val behavioralEndpointPrefixes = listOf(ApiPaths.URL_BEHAVIORAL_V1_PREFIX, ApiPaths.URL_BEHAVIORAL_V2_PREFIX, ApiPaths.URL_BEHAVIORAL_AD_PREFIX)
val behavioralSearchRegex = ApiPaths.URL_BEHAVIORAL_SEARCH_REGEX.toRegex()
val request = chain.request()
var builder = request.url.newBuilder();
val newRequestBuilder = request.newBuilder()

/* Re-add, Redact url query parameters for /behavior, /v2/behavioral_action */
if (behavioralEndpointPaths.any{request.url.encodedPath.matches(it)} ) {
val encodedPath = request.url.encodedPath
val isBehavioralEndpoint = behavioralEndpointPrefixes.any { encodedPath.startsWith(it) } || behavioralSearchRegex.matches(encodedPath)
if (isBehavioralEndpoint ) {
builder = HttpUrl.Builder()
.scheme(request.url.scheme)
.port(request.url.port)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ constructor(@ConstructorSdk val preferences: SharedPreferences) {
get() = preferences.getString(PREF_QUIZZES_SERVICE_URL, "")
set(value) = preferences.edit().putString(PREF_QUIZZES_SERVICE_URL, value).apply()

var mediaServiceUrl: String?
get() = preferences.getString(PREF_MEDIA_SERVICE_URL, "")
set(value) = preferences.edit().putString(PREF_MEDIA_SERVICE_URL, value).apply()

var port: Int
get() = preferences.getInt(PREF_SERVICE_PORT, 443)
set(value) = preferences.edit().putInt(PREF_SERVICE_PORT, value).apply()
Expand Down Expand Up @@ -87,6 +91,7 @@ constructor(@ConstructorSdk val preferences: SharedPreferences) {
const val SESSION_TIME_THRESHOLD = 1000 * 60 * 30
const val PREF_SERVICE_URL = "service_url"
const val PREF_QUIZZES_SERVICE_URL = "quizzes_service_url"
const val PREF_MEDIA_SERVICE_URL = "media_service_url"
const val PREF_SERVICE_PORT = "service_port"
const val PREF_SERVICE_SCHEME = "service_scheme"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package io.constructor.data.model.tracking

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.io.Serializable

@JsonClass(generateAdapter = true)
data class MediaImpressionClickRequestBody(
@Json(name = "banner_ad_id") val bannerAdId: String,
@Json(name = "placement_id") val placementId: String,
@Json(name = "beacon") val beacon: Boolean = true,
@Json(name = "c") val c: String,
@Json(name = "i") val i: String,
@Json(name = "s") val s: Int,
@Json(name = "key") val key: String,
@Json(name = "ui") val ui: String?,
@Json(name = "us") val us: List<String?>,
@Json(name = "_dt") val dt: Long
) : Serializable
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package io.constructor.data.model.tracking

import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
import java.io.Serializable

@JsonClass(generateAdapter = true)
data class MediaImpressionViewRequestBody(
@Json(name = "banner_ad_id") val bannerAdId: String,
@Json(name = "placement_id") val placementId: String,
@Json(name = "beacon") val beacon: Boolean = true,
@Json(name = "c") val c: String,
@Json(name = "i") val i: String,
@Json(name = "s") val s: Int,
@Json(name = "key") val key: String,
@Json(name = "ui") val ui: String?,
@Json(name = "us") val us: List<String?>,
@Json(name = "_dt") val dt: Long
) : Serializable
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ package io.constructor.data.remote
object ApiPaths {
const val URL_BEHAVIORAL_V1_PREFIX = "/behavior"
const val URL_BEHAVIORAL_V2_PREFIX = "/v2/behavioral_action"
const val URL_BEHAVIORAL_AD_PREFIX = "/v2/ad_behavioral_action"
const val URL_BEHAVIORAL_SEARCH_REGEX = "/autocomplete/.*/(search|select|click_through)$"
const val URL_AUTOCOMPLETE = "autocomplete/%s"
const val URL_AUTOCOMPLETE_SELECT_EVENT = "autocomplete/{term}/select"
Expand Down Expand Up @@ -34,4 +35,6 @@ object ApiPaths {
const val URL_QUIZ_CONVERSION_EVENT = "v2/behavioral_action/quiz_conversion"
const val URL_QUIZ_NEXT_QUESTION = "v1/quizzes/%s/next"
const val URL_QUIZ_RESULTS = "v1/quizzes/%s/results"
const val URL_MEDIA_IMPRESSION_VIEW_EVENT = "v2/ad_behavioral_action/display_ad_view"
const val URL_MEDIA_IMPRESSION_CLICK_EVENT = "v2/ad_behavioral_action/display_ad_click"
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,17 @@ package io.constructor.data.remote

import io.constructor.data.model.autocomplete.AutocompleteResponse
import io.constructor.data.model.browse.*
import io.constructor.data.model.tracking.GenericResultClickRequestBody
import io.constructor.data.model.tracking.ItemDetailLoadRequestBody
import io.constructor.data.model.tracking.MediaImpressionClickRequestBody
import io.constructor.data.model.tracking.MediaImpressionViewRequestBody
import io.constructor.data.model.conversion.ConversionRequestBody
import io.constructor.data.model.purchase.PurchaseRequestBody
import io.constructor.data.model.quiz.*
import io.constructor.data.model.recommendations.RecommendationResultClickRequestBody
import io.constructor.data.model.recommendations.RecommendationResultViewRequestBody
import io.constructor.data.model.recommendations.RecommendationsResponse
import io.constructor.data.model.search.*
import io.constructor.data.model.tracking.GenericResultClickRequestBody
import io.reactivex.Completable
import io.reactivex.Single
import okhttp3.ResponseBody
Expand Down Expand Up @@ -137,6 +139,12 @@ interface ConstructorApi {
fun trackQuizConversion(@Body quizConversionRequestBody: QuizConversionRequestBody,
@QueryMap params: Map<String, String>): Completable

@POST
fun trackMediaImpressionView(@Url url: String, @Body mediaImpressionViewRequestBody: MediaImpressionViewRequestBody): Completable

@POST
fun trackMediaImpressionClick(@Url url: String, @Body mediaImpressionClickRequestBody: MediaImpressionClickRequestBody): Completable

@GET
fun getQuizNextQuestion(@Url quizUrl: String): Single<Result<ResponseBody>>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
package io.constructor.core

import android.content.Context
import io.constructor.data.local.PreferencesHelper
import io.constructor.data.memory.ConfigMemoryHolder
import io.constructor.test.createTestDataManager
import io.constructor.util.RxSchedulersOverrideRule
import io.mockk.every
import io.mockk.mockk
import okhttp3.OkHttpClient
import okhttp3.Request
import org.json.JSONObject
import org.junit.Before
import org.junit.Rule
import org.junit.Test

class ConstructorIoIntegrationMediaTrackingTest {

@Rule
@JvmField
val overrideSchedulersRule = RxSchedulersOverrideRule()

private var constructorIo = ConstructorIo
private val ctx = mockk<Context>()
private val preferencesHelper = mockk<PreferencesHelper>()
private val configMemoryHolder = mockk<ConfigMemoryHolder>()
private val timeBetweenTests = 2000L

private val testKey = "key_x6UnCVRZaJgIHFQD"
private val testPlacementId = "home"
private lateinit var bannerAdId: String

@Before
fun setup() {
every { ctx.applicationContext } returns ctx

every { preferencesHelper.apiKey } returns testKey
every { preferencesHelper.id } returns "wacko-the-guid"
every { preferencesHelper.scheme } returns "https"
every { preferencesHelper.serviceUrl } returns "ac.cnstrc.com"
every { preferencesHelper.mediaServiceUrl } returns "behavior.media-cnstrc.com"
every { preferencesHelper.port } returns 443
every { preferencesHelper.defaultItemSection } returns "Products"
every { preferencesHelper.getSessionId(any(), any()) } returns 67

every { configMemoryHolder.autocompleteResultCount } returns null
every { configMemoryHolder.userId } returns "player-three"
every { configMemoryHolder.defaultAnalyticsTags } returns mapOf("appVersion" to "123", "appPlatform" to "Android")
every { configMemoryHolder.testCellParams } returns emptyList()
every { configMemoryHolder.segments } returns emptyList()

val config = ConstructorIoConfig(testKey)
val dataManager = createTestDataManager(preferencesHelper, configMemoryHolder)

constructorIo.testInit(ctx, config, dataManager, preferencesHelper, configMemoryHolder)

bannerAdId = fetchBannerAdId()
}

private fun fetchBannerAdId(): String {
val client = OkHttpClient()
val url = "https://display.media-cnstrc.com/display-ads?key=$testKey&placement_ids=$testPlacementId"
val request = Request.Builder().url(url).build()
client.newCall(request).execute().use { response ->
val body = response.body?.string()
val json = JSONObject(body ?: "{}")
val displayAds = json.getJSONObject("display_ads")
val placementArray = displayAds.optJSONArray(testPlacementId)
if (placementArray != null && placementArray.length() > 0) {
return placementArray.getJSONObject(0).getString("banner_ad_id")
}
val placementObject = displayAds.optJSONObject(testPlacementId)
if (placementObject != null) {
return placementObject.getString("banner_ad_id")
}
}
throw IllegalStateException("Unable to fetch banner ad id for placement $testPlacementId")
}

@Test
fun trackMediaImpressionViewAgainstRealResponse() {
val observer = ConstructorIo.trackMediaImpressionViewInternal(bannerAdId, testPlacementId).test()
observer.assertComplete()
observer.assertNoErrors()
Thread.sleep(timeBetweenTests)
}

@Test
fun trackMediaImpressionClickAgainstRealResponse() {
val observer = ConstructorIo.trackMediaImpressionClickInternal(bannerAdId, testPlacementId).test()
observer.assertComplete()
observer.assertNoErrors()
Thread.sleep(timeBetweenTests)
}
}
Loading
Loading