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