diff --git a/buildSrc/src/main/java/com/virtusize/android/constants/Constants.kt b/buildSrc/src/main/java/com/virtusize/android/constants/Constants.kt
index 38880a61..d44f4723 100644
--- a/buildSrc/src/main/java/com/virtusize/android/constants/Constants.kt
+++ b/buildSrc/src/main/java/com/virtusize/android/constants/Constants.kt
@@ -1,7 +1,7 @@
package com.virtusize.android.constants
object Constants {
- const val COMPILE_SDK = 34
+ const val COMPILE_SDK = 35
const val MIN_SDK = 21
const val TARGET_SDK = 35
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index b5a879cd..19074173 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -23,6 +23,8 @@ truth = "1.4.4"
virtusize = "2.12.21"
virtusizeAuth = "1.1.1"
browser = "1.8.0"
+sentry = "8.34.1"
+sentry_plugin = "6.1.0"
[libraries]
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "activityCompose" }
@@ -51,6 +53,7 @@ virtusize-auth = { module = "com.virtusize.android:virtusize-auth", version.ref
androidx-browser = { group = "androidx.browser", name = "browser", version.ref = "browser" }
coil = { group = "io.coil-kt", name = "coil", version = "2.6.0" }
coil-gif = { group = "io.coil-kt", name = "coil-gif", version = "2.6.0" }
+sentry-android = { module = "io.sentry:sentry-android", version.ref = "sentry" }
[plugins]
android-application = { id = "com.android.application", version.ref = "agp" }
@@ -59,3 +62,4 @@ compose-compiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "
jetbrains-kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-parcelize = { id = "org.jetbrains.kotlin.plugin.parcelize", version.ref = "kotlin" }
nexus-publish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nextPublish" }
+sentry-android = { id = "io.sentry.android.gradle", version.ref = "sentry_plugin" }
diff --git a/virtusize/build.gradle.kts b/virtusize/build.gradle.kts
index 5ac323d2..24992a22 100644
--- a/virtusize/build.gradle.kts
+++ b/virtusize/build.gradle.kts
@@ -5,6 +5,7 @@ plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.compose.compiler)
alias(libs.plugins.jetbrains.kotlin.android)
+ alias(libs.plugins.sentry.android)
`maven-publish`
signing
}
@@ -90,6 +91,8 @@ dependencies {
implementation(libs.coil)
implementation(libs.coil.gif)
+ implementation(libs.sentry.android)
+
debugImplementation(libs.androidx.compose.ui.tooling)
testImplementation(libs.androidx.test.core)
diff --git a/virtusize/src/main/AndroidManifest.xml b/virtusize/src/main/AndroidManifest.xml
index 8b7696cd..bee9ac97 100644
--- a/virtusize/src/main/AndroidManifest.xml
+++ b/virtusize/src/main/AndroidManifest.xml
@@ -9,6 +9,23 @@
android:theme="@style/Theme.AppCompat.Light.NoActionBar"
android:name=".ui.VirtusizeWebViewActivity" />
+
+
+
+
+
+
+
diff --git a/virtusize/src/main/java/com/virtusize/android/VirtusizeImpl.kt b/virtusize/src/main/java/com/virtusize/android/VirtusizeImpl.kt
index d32802c4..8d71e017 100644
--- a/virtusize/src/main/java/com/virtusize/android/VirtusizeImpl.kt
+++ b/virtusize/src/main/java/com/virtusize/android/VirtusizeImpl.kt
@@ -25,9 +25,11 @@ import com.virtusize.android.util.trimI18nText
import com.virtusize.android.util.valueOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
+import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
/**
@@ -47,6 +49,16 @@ internal class VirtusizeImpl(
// Registered message handlers
private val messageHandlers = mutableListOf()
+ // Sentry store ID helper
+ private val sentryStoreId: String?
+ get() = VirtusizeApi.currentStoreId?.value?.toString()
+
+ // Job to track and cancel previous load operation
+ private var loadJob: Job? = null
+
+ // Tracks the last product ID loaded to detect product changes for session tracking
+ private var lastLoadedProductExternalId: String? = null
+
// The Virtusize message handler passes received errors and events to registered message handlers
val messageHandler =
object : VirtusizeMessageHandler {
@@ -60,6 +72,7 @@ internal class VirtusizeImpl(
// Handle different user events from the web view
when (event) {
is VirtusizeEvent.UserAddedProduct -> {
+ VirtusizeSentryTracker.trackWebViewEvent(event.name, sentryStoreId)
scope.launch {
virtusizeRepository.fetchDataForInPageRecommendation(
shouldUpdateUserProducts = true,
@@ -72,12 +85,14 @@ internal class VirtusizeImpl(
}
is VirtusizeEvent.UserAuthData -> {
+ VirtusizeSentryTracker.trackWebViewEvent(event.name, sentryStoreId)
event.data?.let { data ->
virtusizeRepository.updateUserAuthData(data)
}
}
is VirtusizeEvent.UserChangedRecommendationType -> {
+ VirtusizeSentryTracker.trackWebViewEvent(event.name, sentryStoreId)
// Switches the view for InPage based on user selected size recommendation type
var recommendationType: SizeRecommendationType? = null
event.data?.optString("recommendationType")?.let {
@@ -91,6 +106,7 @@ internal class VirtusizeImpl(
}
is VirtusizeEvent.UserLoggedOut, is VirtusizeEvent.UserDeletedData -> {
+ VirtusizeSentryTracker.trackWebViewEvent(event.name, sentryStoreId)
// Clears user related data and updates the session,
// and then re-fetches user products and body profile from the server
scope.launch {
@@ -102,6 +118,7 @@ internal class VirtusizeImpl(
}
is VirtusizeEvent.UserDeletedProduct -> {
+ VirtusizeSentryTracker.trackWebViewEvent(event.name, sentryStoreId)
event.data?.optInt("userProductId")?.let { userProductId ->
virtusizeRepository.deleteUserProduct(userProductId)
}
@@ -115,6 +132,7 @@ internal class VirtusizeImpl(
}
is VirtusizeEvent.UserLoggedIn -> {
+ VirtusizeSentryTracker.trackWebViewEvent(event.name, sentryStoreId)
// Updates the user session and fetches updated user products and body profile from the server
scope.launch {
virtusizeRepository.updateUserSession()
@@ -124,6 +142,10 @@ internal class VirtusizeImpl(
}
is VirtusizeEvent.UserOpenedWidget -> {
+ VirtusizeSentryTracker.trackWebViewEvent(
+ eventName = event.name,
+ storeId = sentryStoreId,
+ )
virtusizeRepository.setLastProductOnVirtusizeWebView(product.externalId)
scope.launch {
virtusizeRepository.fetchDataForInPageRecommendation(
@@ -135,6 +157,7 @@ internal class VirtusizeImpl(
}
is VirtusizeEvent.UserSelectedProduct -> {
+ VirtusizeSentryTracker.trackWebViewEvent(event.name, sentryStoreId)
val userProductId = event.data?.optInt("userProductId")
scope.launch {
virtusizeRepository.fetchDataForInPageRecommendation(
@@ -149,6 +172,7 @@ internal class VirtusizeImpl(
}
is VirtusizeEvent.UserUpdatedBodyMeasurements -> {
+ VirtusizeSentryTracker.trackWebViewEvent(event.name, sentryStoreId)
invalidateCurrentProduct()
// Updates the body recommendation size and switches the view to the body comparison
val sizeRecName = event.data?.optString("sizeRecName")
@@ -160,12 +184,18 @@ internal class VirtusizeImpl(
}
}
- is VirtusizeEvent.UserClosedWidget ->
+ is VirtusizeEvent.UserClosedWidget -> {
+ VirtusizeSentryTracker.trackWebViewEvent(
+ eventName = event.name,
+ storeId = sentryStoreId,
+ )
scope.launch {
virtusizeRepository.updateUserSession()
}
+ }
is VirtusizeEvent.UserClickedLanguageSelector -> {
+ VirtusizeSentryTracker.trackWebViewEvent(event.name, sentryStoreId)
event.data?.optString("language")?.let { language ->
val virtusizeLanguage = VirtusizeLanguage.entries.firstOrNull { it.value == language }
if (virtusizeLanguage != null) {
@@ -174,13 +204,15 @@ internal class VirtusizeImpl(
}
}
+ is VirtusizeEvent.UserSawProduct -> Unit
+
is VirtusizeEvent.UserCreatedSilhouette,
- is VirtusizeEvent.UserSawProduct,
is VirtusizeEvent.UserSawWidgetButton,
is VirtusizeEvent.UserClickedStart,
is VirtusizeEvent.WidgetReady,
- is VirtusizeEvent.Undefined,
- -> Unit
+ -> VirtusizeSentryTracker.trackWebViewEvent(event.name, sentryStoreId)
+
+ is VirtusizeEvent.Undefined -> Unit
}
}
@@ -188,6 +220,10 @@ internal class VirtusizeImpl(
messageHandlers.forEach { messageHandler ->
messageHandler.onError(error)
}
+ VirtusizeSentryTracker.trackError(
+ throwable = Exception(error.message),
+ storeId = sentryStoreId,
+ )
}
}
@@ -322,10 +358,10 @@ internal class VirtusizeImpl(
/**
* @see Virtusize.setApiKey
*/
- override fun setApiKey(apiKey: String) {
- VirtusizeApi.setApiKey(apiKey)
+ override fun setApiKey(newApiKey: String) {
+ VirtusizeApi.setApiKey(newApiKey)
virtusizeViews.forEach { virtusizeView ->
- virtusizeView.virtusizeParams.apiKey = apiKey
+ virtusizeView.virtusizeParams.apiKey = newApiKey
}
}
@@ -357,9 +393,50 @@ internal class VirtusizeImpl(
* @see Virtusize.load
*/
override fun load(virtusizeProduct: VirtusizeProduct) {
- scope.launch {
- productCheck(virtusizeProduct)
+ // Generate a new Sentry session ID when a different product is loaded
+ if (lastLoadedProductExternalId != virtusizeProduct.externalId) {
+ lastLoadedProductExternalId = virtusizeProduct.externalId
+ VirtusizeSentryTracker.generateSessionId()
}
+ loadJob?.cancel()
+ loadJob =
+ scope.launch {
+ if (!isActive) {
+ VirtusizeSentryTracker.trackLoadCancelled(
+ step = "start",
+ externalProductId = virtusizeProduct.externalId,
+ storeId = sentryStoreId,
+ )
+ return@launch
+ }
+ val success = productCheck(virtusizeProduct)
+ if (!isActive) {
+ VirtusizeSentryTracker.trackLoadCancelled(
+ step = "product-check",
+ externalProductId = virtusizeProduct.externalId,
+ storeId = sentryStoreId,
+ )
+ return@launch
+ }
+ // productCheckData is set only when the API call itself succeeded (valid or invalid product)
+ val apiSucceeded = virtusizeProduct.productCheckData != null
+ VirtusizeSentryTracker.trackProductCheck(
+ externalProductId = virtusizeProduct.externalId,
+ isValid = success,
+ storeId = if (apiSucceeded) sentryStoreId else null,
+ )
+ if (apiSucceeded) {
+ VirtusizeSentryTracker.trackUserSawProduct(
+ externalProductId = virtusizeProduct.externalId,
+ storeId = sentryStoreId,
+ )
+ } else {
+ VirtusizeSentryTracker.trackError(
+ throwable = Exception("Product check failed"),
+ storeId = null,
+ )
+ }
+ }
}
/**
@@ -402,9 +479,14 @@ internal class VirtusizeImpl(
onError: ((VirtusizeError) -> Unit)?,
) {
scope.launch {
+ VirtusizeSentryTracker.trackSendOrder(order, sentryStoreId)
virtusizeRepository.sendOrder(params, order, { _ ->
onSuccess?.invoke()
}, { error ->
+ VirtusizeSentryTracker.trackError(
+ throwable = Exception(error.message),
+ storeId = sentryStoreId,
+ )
onError?.invoke(error)
})
}
@@ -419,9 +501,14 @@ internal class VirtusizeImpl(
onError: ErrorResponseHandler?,
) {
scope.launch {
+ VirtusizeSentryTracker.trackSendOrder(order, sentryStoreId)
virtusizeRepository.sendOrder(params, order, { data ->
onSuccess?.onSuccess(data)
}, { error ->
+ VirtusizeSentryTracker.trackError(
+ throwable = Exception(error.message),
+ storeId = sentryStoreId,
+ )
onError?.onError(error)
})
}
diff --git a/virtusize/src/main/java/com/virtusize/android/VirtusizeSentryTracker.kt b/virtusize/src/main/java/com/virtusize/android/VirtusizeSentryTracker.kt
new file mode 100644
index 00000000..f1a1ff4c
--- /dev/null
+++ b/virtusize/src/main/java/com/virtusize/android/VirtusizeSentryTracker.kt
@@ -0,0 +1,167 @@
+package com.virtusize.android
+
+import com.virtusize.android.data.local.VirtusizeOrder
+import io.sentry.Sentry
+import java.util.UUID
+
+/**
+ * Utility for Sentry metrics and structured logs tracking in the Virtusize SDK.
+ *
+ * Requires Sentry Android SDK >= 8.0.0.
+ * Configured via AndroidManifest.xml meta-data (DSN, logs, traces sample rate, environment).
+ */
+internal object VirtusizeSentryTracker {
+ // MARK: - Session Management
+
+ /** The current active session ID, set by Virtusize.load. */
+ var currentSessionId: String = ""
+
+ init {
+ Sentry.configureScope { scope ->
+ scope.setTag("sdk_version", BuildConfig.VERSION_NANE)
+ scope.setTag("sdk_platform", "android")
+ }
+ }
+
+ /**
+ * Generates a new UUID session ID, stores it as the current session, and returns it.
+ * Also configures the Sentry scope so all subsequent logs are tagged with the new session ID.
+ */
+ fun generateSessionId(): String {
+ currentSessionId = UUID.randomUUID().toString()
+ Sentry.configureScope { scope ->
+ scope.setTag("session_id", currentSessionId)
+ }
+ return currentSessionId
+ }
+
+ // MARK: - Platform Override
+
+ /**
+ * Overrides the `sdk_platform` Sentry scope tag. Call this when the SDK is running inside a
+ * wrapper (e.g. Flutter) so that the tag reflects the actual integration platform.
+ */
+ fun setSDKPlatform(platform: String) {
+ Sentry.configureScope { scope ->
+ scope.setTag("sdk_platform", platform)
+ }
+ }
+
+ // MARK: - Metrics (Counters)
+
+ fun increment(
+ key: String,
+ tags: Map = emptyMap(),
+ ) {
+ Sentry.metrics().count(key, 1.0)
+ }
+
+ // MARK: - Logs
+
+ fun logInfo(
+ message: String,
+ attributes: Map = emptyMap(),
+ ) {
+ Sentry.logger().info(message, attributes)
+ }
+
+ fun logWarning(
+ message: String,
+ attributes: Map = emptyMap(),
+ ) {
+ Sentry.logger().warn(message, attributes)
+ }
+
+ fun logError(
+ message: String,
+ attributes: Map = emptyMap(),
+ ) {
+ Sentry.logger().error(message, attributes)
+ }
+
+ // MARK: - WebView Events
+
+ fun trackWebViewEvent(
+ eventName: String,
+ storeId: String? = null,
+ ) {
+ val tags = mutableMapOf("event_name" to eventName)
+ storeId?.let { tags["store_id"] = it }
+ logInfo("webview-$eventName", tags)
+ }
+
+ fun trackUserSawProduct(
+ storeId: String? = null,
+ externalProductId: String? = null,
+ ) {
+ val tags = buildTags(storeId = storeId)
+ externalProductId?.let { tags["external_product_id"] = it }
+ increment("user.saw.product", tags)
+ logInfo("user-saw-product", tags)
+ }
+
+ // MARK: - Product Check
+
+ fun trackProductCheck(
+ externalProductId: String,
+ isValid: Boolean,
+ storeId: String? = null,
+ ) {
+ val tags =
+ buildTags(storeId = storeId) +
+ mapOf("external_product_id" to externalProductId, "is_valid" to isValid.toString())
+ logInfo("product-check", tags)
+ }
+
+ fun trackLoadCancelled(
+ step: String,
+ externalProductId: String,
+ storeId: String? = null,
+ ) {
+ val tags =
+ buildTags(storeId = storeId) +
+ mapOf("external_product_id" to externalProductId, "step" to step)
+ logWarning("load-cancelled", tags)
+ }
+
+ // MARK: - Order
+
+ fun trackSendOrder(
+ order: VirtusizeOrder,
+ storeId: String? = null,
+ ) {
+ val externalProductIds = order.items.mapNotNull { it.paramsToMap()["externalProductId"] as? String }
+ if (externalProductIds.isEmpty()) {
+ val tags = buildTags(storeId = storeId)
+ increment("order.sent", tags)
+ } else {
+ for (externalProductId in externalProductIds) {
+ val tags = buildTags(storeId = storeId)
+ tags["external_product_id"] = externalProductId
+ increment("order.sent", tags)
+ }
+ }
+ logInfo("order-sent", buildTags(storeId = storeId))
+ }
+
+ // MARK: - Error
+
+ fun trackError(
+ throwable: Throwable,
+ storeId: String? = null,
+ ) {
+ val tags =
+ buildTags(storeId = storeId) +
+ mapOf("error_type" to throwable::class.java.simpleName)
+ increment("error", tags)
+ logError(throwable.localizedMessage ?: throwable.message ?: throwable::class.java.simpleName, tags)
+ }
+
+ // MARK: - Private
+
+ private fun buildTags(storeId: String? = null): MutableMap {
+ val tags = mutableMapOf()
+ storeId?.let { tags["store_id"] = it }
+ return tags
+ }
+}
diff --git a/virtusize/src/main/java/com/virtusize/android/flutter/VirtusizeFlutter.kt b/virtusize/src/main/java/com/virtusize/android/flutter/VirtusizeFlutter.kt
index a1a8ca1d..b6384ff1 100644
--- a/virtusize/src/main/java/com/virtusize/android/flutter/VirtusizeFlutter.kt
+++ b/virtusize/src/main/java/com/virtusize/android/flutter/VirtusizeFlutter.kt
@@ -5,6 +5,7 @@ import android.content.Context
import com.virtusize.android.ErrorResponseHandler
import com.virtusize.android.SuccessResponseHandler
import com.virtusize.android.VirtusizeRepository
+import com.virtusize.android.VirtusizeSentryTracker
import com.virtusize.android.data.local.VirtusizeError
import com.virtusize.android.data.local.VirtusizeLanguage
import com.virtusize.android.data.local.VirtusizeMessageHandler
@@ -35,6 +36,7 @@ interface VirtusizeFlutter {
) = if (!Companion::instance.isInitialized) {
synchronized(this) {
if (!Companion::instance.isInitialized) {
+ VirtusizeSentryTracker.setSDKPlatform("flutter-android")
VirtusizeFlutterImpl(
context = context,
params = params,
diff --git a/virtusize/src/main/res/font/subset_noto_sans_jp_bold.ttf b/virtusize/src/main/res/font/subset_noto_sans_jp_bold.ttf
index e81af91d..f92f74a4 100644
Binary files a/virtusize/src/main/res/font/subset_noto_sans_jp_bold.ttf and b/virtusize/src/main/res/font/subset_noto_sans_jp_bold.ttf differ
diff --git a/virtusize/src/main/res/font/subset_noto_sans_jp_regular.ttf b/virtusize/src/main/res/font/subset_noto_sans_jp_regular.ttf
index 7ff60ea9..37c036be 100644
Binary files a/virtusize/src/main/res/font/subset_noto_sans_jp_regular.ttf and b/virtusize/src/main/res/font/subset_noto_sans_jp_regular.ttf differ
diff --git a/virtusize/src/main/res/font/subset_noto_sans_kr_bold.ttf b/virtusize/src/main/res/font/subset_noto_sans_kr_bold.ttf
index 12ad4a96..033b6851 100644
Binary files a/virtusize/src/main/res/font/subset_noto_sans_kr_bold.ttf and b/virtusize/src/main/res/font/subset_noto_sans_kr_bold.ttf differ
diff --git a/virtusize/src/main/res/font/subset_noto_sans_kr_regular.ttf b/virtusize/src/main/res/font/subset_noto_sans_kr_regular.ttf
index 7202223b..a3cef382 100644
Binary files a/virtusize/src/main/res/font/subset_noto_sans_kr_regular.ttf and b/virtusize/src/main/res/font/subset_noto_sans_kr_regular.ttf differ