Skip to content
Merged
26 changes: 26 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -544,6 +544,19 @@ ConstructorIo.trackSearchSubmit(request)
// Track when search results are loaded into view (searchTerm, resultCount, customerIds of shown items)
ConstructorIo.trackSearchResultsLoaded("tooth", 789, arrayOf("1234567-AB", "1234567-AB"))

// Track when search results are loaded into view using a request builder (searchTerm, resultCount)
val searchResultsLoadedRequest = SearchResultsLoadedData.build("tooth", 789) {
setItems(listOf(TrackingItem("1234567-AB", "RED", null, null)))
setSectionName("Products")
setUrl("https://constructor.io/search?q=tooth")
setResultId("179b8a0e-3799-4a31-be87-127b06871de2")
setResultPage(3) // or setResultOffset(20) on infinite-scroll pages: set only one of the two
setSortOrder("ascending")
setSortBy("price")
setSelectedFilters(mapOf("brand" to listOf("XYZ"), "color" to listOf("black")))
}
ConstructorIo.trackSearchResultsLoaded(searchResultsLoadedRequest)

// Track when a search result is clicked (itemName, customerId, searchTerm, sectionName, resultId)
ConstructorIo.trackSearchResultClick("Fashionable Toothpicks", "1234567-AB", "tooth", "Products", "179b8a0e-3799-4a31-be87-127b06871de2")

Expand Down Expand Up @@ -572,6 +585,19 @@ ConstructorIo.trackBrowseResultsLoaded("group_id", "Snacks", 674)
// Track when browse results are loaded into view (filterName, filterValue, itemIds, resultCount)
ConstructorIo.trackBrowseResultsLoaded("group_id", "Snacks", arrayOf("1234", "2345"), 674)

// Track when browse results are loaded into view using a request builder (filterName, filterValue, resultCount)
val browseResultsLoadedRequest = BrowseResultsLoadedData.build("group_id", "Snacks", 674) {
setItems(listOf(TrackingItem("7654321-BA", "RED", null, null)))
setSectionName("Products")
setUrl("https://constructor.io/browse/group_id/Snacks")
setResultId("179b8a0e-3799-4a31-be87-127b06871de2")
setResultPage(3) // or setResultOffset(20) on infinite-scroll pages: set only one of the two
setSortOrder("ascending")
setSortBy("price")
setSelectedFilters(mapOf("brand" to listOf("XYZ"), "color" to listOf("black")))
}
ConstructorIo.trackBrowseResultsLoaded(browseResultsLoadedRequest)

// Track when a browse result is clicked (filterName, filterValue, customerId, resultPositionOnPage, sectionName, resultId)
ConstructorIo.trackBrowseResultClick("group_id", "Snacks", "7654321-BA", "4", "Products", "179b8a0e-3799-4a31-be87-127b06871de2")

Expand Down
98 changes: 94 additions & 4 deletions library/src/main/java/io/constructor/core/ConstructorIo.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1564,8 +1564,47 @@ object ConstructorIo {
disposable.add(completable.subscribeOn(Schedulers.io()).subscribe({}, { t -> e("Search Results Loaded error: ${t.message}") }))
}

internal fun trackSearchResultsLoadedInternal(term: String, resultCount: Int, customerIds: Array<String>? = null, items: Array<TrackingItem>? = null, analyticsTags: Map<String, String>? = null): Completable {
/**
* Tracks search results loaded (a.k.a. search results viewed) events, with support for the
* result ID, pagination, sorting and selected filters of the search results.
*
* Example:
* ```
* val request = SearchResultsLoadedData.build("tooth", 789) {
* setItems(listOf(TrackingItem("1234", "2345", "camp1234", "owner-A")))
* setSectionName("Products")
* setAnalyticsTags(mapOf("campaign" to "summer_sale"))
* setResultId("179b8a0e-3799-4a31-be87-127b06871de2")
* setResultPage(3)
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
* setSortOrder("ascending")
* setSortBy("price")
* setSelectedFilters(mapOf("brand" to listOf("XYZ"), "color" to listOf("black")))
* }
* ConstructorIo.trackSearchResultsLoaded(request)
* ```
* @param request the search results loaded request object holding all of the tracking parameters
*/
fun trackSearchResultsLoaded(request: SearchResultsLoadedData) {
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
val completable = trackSearchResultsLoadedInternal(
term = request.term,
resultCount = request.resultCount,
items = request.items?.toTypedArray(),
Comment thread
Alexey-Pavlov marked this conversation as resolved.
sectionName = request.sectionName,
url = request.url,
analyticsTags = request.analyticsTags,
resultId = request.resultId,
resultPage = request.resultPage,
resultOffset = request.resultOffset,
sortOrder = request.sortOrder,
sortBy = request.sortBy,
selectedFilters = request.selectedFilters
)
disposable.add(completable.subscribeOn(Schedulers.io()).subscribe({}, { t -> e("Search Results Loaded error: ${t.message}") }))
}

internal fun trackSearchResultsLoadedInternal(term: String, resultCount: Int, customerIds: Array<String>? = null, items: Array<TrackingItem>? = null, sectionName: String? = null, url: String = "Not Available", analyticsTags: Map<String, String>? = null, resultId: String? = null, resultPage: Int? = null, resultOffset: Int? = null, sortOrder: String? = null, sortBy: String? = null, selectedFilters: Map<String, List<String>>? = null): Completable {
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
preferenceHelper.getSessionId(sessionIncrementHandler)
val section = sectionName ?: preferenceHelper.defaultItemSection
val itemsList: List<TrackingItem>? = when {
items != null -> items.toList()
customerIds != null -> customerIds.map { id -> TrackingItem(id, null, null, null) }
Expand All @@ -1575,7 +1614,13 @@ object ConstructorIo {
term,
itemsList,
resultCount,
"Not Available",
resultId,
resultPage,
resultOffset,
sortOrder,
sortBy,
selectedFilters,
url,
BuildConfig.CLIENT_VERSION,
preferenceHelper.id,
preferenceHelper.getSessionId(),
Expand All @@ -1584,7 +1629,7 @@ object ConstructorIo {
configMemoryHolder.segments,
mergeAnalyticsTags(configMemoryHolder.defaultAnalyticsTags, analyticsTags),
true,
preferenceHelper.defaultItemSection,
section,
System.currentTimeMillis()
)

Expand Down Expand Up @@ -1845,7 +1890,46 @@ object ConstructorIo {
}))
}

internal fun trackBrowseResultsLoadedInternal(filterName: String, filterValue: String, itemIds: Array<String>? = null, items: Array<TrackingItem>? = null, resultCount: Int, sectionName: String? = null, url: String = "Not Available", analyticsTags: Map<String, String>? = null): Completable {
/**
* Tracks browse result loaded (a.k.a. browse results viewed) events, with support for the
* result ID, pagination, sorting and selected filters of the browse results.
*
* Example:
* ```
* val request = BrowseResultsLoadedData.build("group_id", "Snacks", 674) {
* setItems(listOf(TrackingItem("1234", "2345", "camp1234", "owner-A")))
* setSectionName("Products")
* setAnalyticsTags(mapOf("campaign" to "summer_sale"))
* setResultId("179b8a0e-3799-4a31-be87-127b06871de2")
* setResultPage(3)
* setSortOrder("ascending")
* setSortBy("price")
* setSelectedFilters(mapOf("brand" to listOf("XYZ"), "color" to listOf("black")))
* }
* ConstructorIo.trackBrowseResultsLoaded(request)
* ```
* @param request the browse results loaded request object holding all of the tracking parameters
*/
fun trackBrowseResultsLoaded(request: BrowseResultsLoadedData) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The positional arguments used when calling trackBrowseResultsLoadedInternal inside this method are inconsistent with the named-argument style used in the search counterpart. request.filterName and request.filterValue are passed positionally:

val completable = trackBrowseResultsLoadedInternal(
    request.filterName,
    request.filterValue,
    items = request.items?.toTypedArray(),
    ...

For readability and consistency with trackSearchResultsLoaded(request), prefer fully named arguments:

val completable = trackBrowseResultsLoadedInternal(
    filterName = request.filterName,
    filterValue = request.filterValue,
    items = request.items?.toTypedArray(),
    ...

val completable = trackBrowseResultsLoadedInternal(
request.filterName,
request.filterValue,
items = request.items?.toTypedArray(),
resultCount = request.resultCount,
sectionName = request.sectionName,
url = request.url,
analyticsTags = request.analyticsTags,
resultId = request.resultId,
resultPage = request.resultPage,
resultOffset = request.resultOffset,
sortOrder = request.sortOrder,
sortBy = request.sortBy,
selectedFilters = request.selectedFilters
)
disposable.add(completable.subscribeOn(Schedulers.io()).subscribe({}, { t -> e("Browse Results Loaded error: ${t.message}") }))
}

internal fun trackBrowseResultsLoadedInternal(filterName: String, filterValue: String, itemIds: Array<String>? = null, items: Array<TrackingItem>? = null, resultCount: Int, sectionName: String? = null, url: String = "Not Available", analyticsTags: Map<String, String>? = null, resultId: String? = null, resultPage: Int? = null, resultOffset: Int? = null, sortOrder: String? = null, sortBy: String? = null, selectedFilters: Map<String, List<String>>? = null): Completable {
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
preferenceHelper.getSessionId(sessionIncrementHandler)
val section = sectionName ?: preferenceHelper.defaultItemSection
val itemsList: List<TrackingItem>? = when {
Expand All @@ -1858,6 +1942,12 @@ object ConstructorIo {
filterValue,
itemsList,
resultCount,
resultId,
resultPage,
resultOffset,
sortOrder,
sortBy,
selectedFilters,
url,
BuildConfig.CLIENT_VERSION,
preferenceHelper.id,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package io.constructor.data.builder

import io.constructor.data.model.common.TrackingItem

/**
* Create a Browse Results Loaded tracking request object utilizing a builder
*/
class BrowseResultsLoadedData(
val filterName: String,
val filterValue: String,
val resultCount: Int,
val items: List<TrackingItem>? = null,
val sectionName: String? = null,
val url: String = "Not Available",
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
val analyticsTags: Map<String, String>? = null,
val resultId: String? = null,
val resultPage: Int? = null,
val resultOffset: Int? = null,
val sortOrder: String? = null,
val sortBy: String? = null,
val selectedFilters: Map<String, List<String>>? = null,
) {
private constructor(builder: Builder) : this(
builder.filterName,
builder.filterValue,
builder.resultCount,
builder.items,
builder.sectionName,
builder.url,
builder.analyticsTags,
builder.resultId,
builder.resultPage,
builder.resultOffset,
builder.sortOrder,
builder.sortBy,
builder.selectedFilters,
)

companion object {
inline fun build(filterName: String, filterValue: String, resultCount: Int, block: Builder.() -> Unit = {}) = Builder(filterName, filterValue, resultCount).apply(block).build()

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important Issue: Same mutual-exclusivity concern as SearchResultsLoadedData: resultPage and resultOffset can both be set without any validation. The BrowseResultsLoadedData.Builder.build() should also enforce:

fun build(): BrowseResultsLoadedData {
    require(resultPage == null || resultOffset == null) {
        "Only one of resultPage or resultOffset may be set, not both."
    }
    return BrowseResultsLoadedData(this)
}

}

class Builder(
val filterName: String,
val filterValue: String,
val resultCount: Int
) {
var items: List<TrackingItem>? = null
var sectionName: String? = null
var url: String = "Not Available"
var analyticsTags: Map<String, String>? = null
var resultId: String? = null
var resultPage: Int? = null
var resultOffset: Int? = null
var sortOrder: String? = null
var sortBy: String? = null
var selectedFilters: Map<String, List<String>>? = null

fun setItems(items: List<TrackingItem>): Builder = apply { this.items = items }
fun setSectionName(sectionName: String): Builder = apply { this.sectionName = sectionName }
fun setUrl(url: String): Builder = apply { this.url = url }
fun setAnalyticsTags(analyticsTags: Map<String, String>): Builder = apply { this.analyticsTags = analyticsTags }
fun setResultId(resultId: String): Builder = apply { this.resultId = resultId }
fun setResultPage(resultPage: Int): Builder = apply { this.resultPage = resultPage }
fun setResultOffset(resultOffset: Int): Builder = apply { this.resultOffset = resultOffset }
fun setSortOrder(sortOrder: String): Builder = apply { this.sortOrder = sortOrder }
fun setSortBy(sortBy: String): Builder = apply { this.sortBy = sortBy }
fun setSelectedFilters(selectedFilters: Map<String, List<String>>): Builder = apply { this.selectedFilters = selectedFilters }
fun build(): BrowseResultsLoadedData = BrowseResultsLoadedData(this)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
package io.constructor.data.builder

import io.constructor.data.model.common.TrackingItem

/**
* Create a Search Results Loaded tracking request object utilizing a builder
*/
class SearchResultsLoadedData(
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
val term: String,
val resultCount: Int,
val items: List<TrackingItem>? = null,
Comment thread
Alexey-Pavlov marked this conversation as resolved.
val sectionName: String? = null,
val url: String = "Not Available",
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.
val analyticsTags: Map<String, String>? = null,
val resultId: String? = null,
val resultPage: Int? = null,
val resultOffset: Int? = null,
val sortOrder: String? = null,
val sortBy: String? = null,
val selectedFilters: Map<String, List<String>>? = null,
) {
private constructor(builder: Builder) : this(
builder.term,
builder.resultCount,
builder.items,
builder.sectionName,
builder.url,
builder.analyticsTags,
builder.resultId,
builder.resultPage,
builder.resultOffset,
builder.sortOrder,
builder.sortBy,
builder.selectedFilters,
)

companion object {
inline fun build(term: String, resultCount: Int, block: Builder.() -> Unit = {}) = Builder(term, resultCount).apply(block).build()
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Important Issue: The README (and KDoc) comments throughout state that resultPage and resultOffset are mutually exclusive — "set only one of the two". However, neither the Builder.build() method nor the SearchResultsLoadedData class enforces this invariant. A caller can silently set both, and the API will receive two conflicting values with no warning.

Consider adding a guard in build():

fun build(): SearchResultsLoadedData {
    require(resultPage == null || resultOffset == null) {
        "Only one of resultPage or resultOffset may be set, not both."
    }
    return SearchResultsLoadedData(this)
}

}

class Builder(
val term: String,
val resultCount: Int
) {
var items: List<TrackingItem>? = null
var sectionName: String? = null
var url: String = "Not Available"
var analyticsTags: Map<String, String>? = null
var resultId: String? = null
var resultPage: Int? = null
var resultOffset: Int? = null
var sortOrder: String? = null
var sortBy: String? = null
var selectedFilters: Map<String, List<String>>? = null

fun setItems(items: List<TrackingItem>): Builder = apply { this.items = items }
fun setSectionName(sectionName: String): Builder = apply { this.sectionName = sectionName }
fun setUrl(url: String): Builder = apply { this.url = url }
fun setAnalyticsTags(analyticsTags: Map<String, String>): Builder = apply { this.analyticsTags = analyticsTags }
fun setResultId(resultId: String): Builder = apply { this.resultId = resultId }
fun setResultPage(resultPage: Int): Builder = apply { this.resultPage = resultPage }
fun setResultOffset(resultOffset: Int): Builder = apply { this.resultOffset = resultOffset }
fun setSortOrder(sortOrder: String): Builder = apply { this.sortOrder = sortOrder }
fun setSortBy(sortBy: String): Builder = apply { this.sortBy = sortBy }
fun setSelectedFilters(selectedFilters: Map<String, List<String>>): Builder = apply { this.selectedFilters = selectedFilters }
fun build(): SearchResultsLoadedData = SearchResultsLoadedData(this)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,12 @@ data class BrowseResultLoadRequestBody(
@Json(name = "filter_value") val filterValue: String,
@Json(name = "items") val items: List<TrackingItem>?,
@Json(name = "result_count") val resultCount: Int,
@Json(name = "result_id") val resultId: String?,
@Json(name = "result_page") val resultPage: Int?,
@Json(name = "result_offset") val resultOffset: Int?,
@Json(name = "sort_order") val sortOrder: String?,
@Json(name = "sort_by") val sortBy: String?,
@Json(name = "selected_filters") val selectedFilters: Map<String, List<String>>?,
@Json(name = "url") val url: String,
@Json(name = "c") val c: String,
@Json(name = "i") val i: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,12 @@ data class SearchResultLoadRequestBody(
@Json(name = "search_term") val searchTerm: String,
@Json(name = "items") val items: List<TrackingItem>?,
@Json(name = "result_count") val resultCount: Int,
@Json(name = "result_id") val resultId: String?,
@Json(name = "result_page") val resultPage: Int?,
@Json(name = "result_offset") val resultOffset: Int?,
@Json(name = "sort_order") val sortOrder: String?,
@Json(name = "sort_by") val sortBy: String?,
@Json(name = "selected_filters") val selectedFilters: Map<String, List<String>>?,
@Json(name = "url") val url: String,
@Json(name = "c") val c: String,
@Json(name = "i") val i: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,7 @@ class ConstructorIoIntegrationQuizTest {
fun getQuizNextQuestionWithSingleTypeAnswerAgainstRealResponse() {
val answers = listOf(listOf("1"))
val request = QuizRequest.Builder("test-quiz")
.setQuizSessionId("bc48a85d-2f45-4c91-ba3a-dcf655b33831")
Comment thread
constructor-claude-bedrock[bot] marked this conversation as resolved.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: The same hardcoded quizSessionId UUID (bc48a85d-2f45-4c91-ba3a-dcf655b33831) is duplicated across every quiz integration test that was modified. Consider extracting it to a shared constant at the top of the test class to make it easier to update and to signal that all tests share the same session:

private val TEST_QUIZ_SESSION_ID = "bc48a85d-2f45-4c91-ba3a-dcf655b33831"

This also makes it clearer whether using the same session ID across tests is intentional.

.setAnswers(answers)
.build()
val observer = constructorIo.getQuizNextQuestion(request).test()
Expand Down Expand Up @@ -130,6 +131,7 @@ class ConstructorIoIntegrationQuizTest {
listOf("1", "2")
)
val request = QuizRequest.Builder("test-quiz")
.setQuizSessionId("bc48a85d-2f45-4c91-ba3a-dcf655b33831")
.setAnswers(answers)
.build()
val observer = constructorIo.getQuizNextQuestion(request).test()
Expand Down Expand Up @@ -157,6 +159,7 @@ class ConstructorIoIntegrationQuizTest {
listOf("seen")
)
val request = QuizRequest.Builder("test-quiz")
.setQuizSessionId("bc48a85d-2f45-4c91-ba3a-dcf655b33831")
.setAnswers(answers)
.build()
val observer = constructorIo.getQuizNextQuestion(request).test()
Expand Down Expand Up @@ -186,6 +189,7 @@ class ConstructorIoIntegrationQuizTest {
listOf("true")
)
val request = QuizRequest.Builder("test-quiz")
.setQuizSessionId("bc48a85d-2f45-4c91-ba3a-dcf655b33831")
.setAnswers(answers)
.build()
val observer = constructorIo.getQuizNextQuestion(request).test()
Expand All @@ -208,6 +212,7 @@ class ConstructorIoIntegrationQuizTest {
listOf("seen")
)
val request = QuizRequest.Builder("test-quiz")
.setQuizSessionId("bc48a85d-2f45-4c91-ba3a-dcf655b33831")
.setAnswers(answers)
.build()
val observer = constructorIo.getQuizResults(request).test()
Expand Down Expand Up @@ -255,7 +260,7 @@ class ConstructorIoIntegrationQuizTest {
listOf("1", "2"),
listOf("seen")
)
val quizResult = constructorIo.getQuizNextQuestionCRT("test-quiz", answers)
val quizResult = constructorIo.getQuizNextQuestionCRT("test-quiz", answers, quizSessionId = "bc48a85d-2f45-4c91-ba3a-dcf655b33831")
assertNotNull(quizResult?.quizId)
assertNotNull(quizResult?.quizVersionId)
assertNotNull(quizResult?.quizSessionId)
Expand All @@ -278,7 +283,7 @@ class ConstructorIoIntegrationQuizTest {
listOf("1", "2"),
listOf("seen")
)
val quizResult = constructorIo.getQuizResultsCRT("test-quiz", answers)
val quizResult = constructorIo.getQuizResultsCRT("test-quiz", answers, quizSessionId = "bc48a85d-2f45-4c91-ba3a-dcf655b33831")
assertEquals(quizResult?.quizId, "test-quiz");
assertNotNull(quizResult?.quizVersionId)
assertNotNull(quizResult?.quizSessionId)
Expand Down
Loading
Loading