Skip to content
Draft
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
9 changes: 9 additions & 0 deletions platforms/android/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -282,6 +282,7 @@ ShopifyCheckoutKit.configure {
),
)
it.logLevel = LogLevel.ERROR
it.telemetry = Telemetry(enabled = false)
}
```

Expand All @@ -293,6 +294,14 @@ ShopifyCheckoutKit.configure {
| `preloading` | `Preloading(enabled = true)` | Enables best-effort checkout preloading before presentation. |
| `title` | `null` | Runtime override for the checkout sheet header title. When `null`, the SDK uses the localized `checkout_web_view_title` string resource. |
| `allowedMessageOrigins` | `emptySet()` | Extra origins allowed to send checkout protocol messages. |
| `telemetry` | `Telemetry(enabled = true)` | Sends anonymous diagnostic metrics to Shopify. Set `enabled` to `false` to opt out. |

Checkout Kit reports bounded counts for checkout errors, protocol decoding
failures, and navigation retries, plus navigation duration histograms. These
diagnostics never include checkout URLs, message payloads, buyer data, or
checkout, order, customer, or shop identifiers. Disabling telemetry stops new
collection and discards measurements that have not already been handed to the
operating system for delivery.

### Color schemes

Expand Down
16 changes: 16 additions & 0 deletions platforms/android/lib/api/lib.api
Original file line number Diff line number Diff line change
Expand Up @@ -494,13 +494,15 @@ public final class com/shopify/checkoutkit/Configuration {
public final fun component5 ()Lcom/shopify/checkoutkit/Preloading;
public final fun component6 ()Ljava/lang/String;
public final fun component7 ()Ljava/util/Set;
public final fun component8 ()Lcom/shopify/checkoutkit/Telemetry;
public fun equals (Ljava/lang/Object;)Z
public final fun getAllowedMessageOrigins ()Ljava/util/Set;
public final fun getAppearance ()Lcom/shopify/checkoutkit/CheckoutAppearance;
public final fun getLogLevel ()Lcom/shopify/checkoutkit/LogLevel;
public final fun getPlatform ()Lcom/shopify/checkoutkit/Platform;
public final fun getPreloading ()Lcom/shopify/checkoutkit/Preloading;
public final fun getSheet ()Lcom/shopify/checkoutkit/CheckoutSheetOptions;
public final fun getTelemetry ()Lcom/shopify/checkoutkit/Telemetry;
public final fun getTitle ()Ljava/lang/String;
public fun hashCode ()I
public final fun setAllowedMessageOrigins (Ljava/util/Set;)V
Expand All @@ -509,6 +511,7 @@ public final class com/shopify/checkoutkit/Configuration {
public final fun setPlatform (Lcom/shopify/checkoutkit/Platform;)V
public final fun setPreloading (Lcom/shopify/checkoutkit/Preloading;)V
public final fun setSheet (Lcom/shopify/checkoutkit/CheckoutSheetOptions;)V
public final fun setTelemetry (Lcom/shopify/checkoutkit/Telemetry;)V
public final fun setTitle (Ljava/lang/String;)V
public fun toString ()Ljava/lang/String;
}
Expand Down Expand Up @@ -710,3 +713,16 @@ public abstract interface class com/shopify/checkoutkit/StorefrontCustomizer {
public abstract fun customize (Lcom/shopify/checkoutkit/ColorsBuilder;)V
}

public final class com/shopify/checkoutkit/Telemetry {
public fun <init> ()V
public fun <init> (Z)V
public synthetic fun <init> (ZILkotlin/jvm/internal/DefaultConstructorMarker;)V
public final fun component1 ()Z
public final fun copy (Z)Lcom/shopify/checkoutkit/Telemetry;
public static synthetic fun copy$default (Lcom/shopify/checkoutkit/Telemetry;ZILjava/lang/Object;)Lcom/shopify/checkoutkit/Telemetry;
public fun equals (Ljava/lang/Object;)Z
public final fun getEnabled ()Z
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package com.shopify.checkoutkit

import android.os.Looper
import com.shopify.checkoutkit.ShopifyCheckoutKit.log
import com.shopify.checkoutkit.telemetry.TelemetryDecodeFailureType
import com.shopify.checkoutkit.telemetry.TelemetryProtocolDecodeErrorMetric
import com.shopify.checkoutkit.telemetry.TelemetryProtocolMethod
import com.shopify.ucp.embedded.checkout.Checkout
import com.shopify.ucp.embedded.checkout.EcpRequest
import com.shopify.ucp.embedded.checkout.EmbeddedCheckoutProtocol
Expand Down Expand Up @@ -122,6 +125,12 @@ public object CheckoutProtocol {
ProtocolClient().onDecodeError { method, error, params ->
log.e(LOG_TAG, "Failed to decode $method params", error)
log.d(LOG_TAG, "Raw $method params: $params")
CheckoutTelemetry.recorder.recordProtocolDecodeError(
TelemetryProtocolDecodeErrorMetric(
method = TelemetryProtocolMethod.fromMethod(method),
failureType = TelemetryDecodeFailureType.Params,
),
)
},
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import android.net.Uri
import android.os.Build
import android.os.Handler
import android.os.Looper
import android.os.SystemClock
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
Expand All @@ -33,8 +34,18 @@ import androidx.core.net.toUri
import androidx.webkit.WebSettingsCompat
import androidx.webkit.WebViewFeature
import com.shopify.checkoutkit.ShopifyCheckoutKit.log
import com.shopify.checkoutkit.telemetry.TelemetryErrorCategory
import com.shopify.checkoutkit.telemetry.TelemetryErrorCode
import com.shopify.checkoutkit.telemetry.TelemetryErrorMetric
import com.shopify.checkoutkit.telemetry.TelemetryErrorStage
import com.shopify.checkoutkit.telemetry.TelemetryNavigationDurationMetric
import com.shopify.checkoutkit.telemetry.TelemetryNavigationDurationResult
import com.shopify.checkoutkit.telemetry.TelemetryNavigationRetryMetric
import com.shopify.checkoutkit.telemetry.TelemetryNavigationRetryReason
import com.shopify.checkoutkit.telemetry.TelemetryNavigationRetryResult
import java.util.concurrent.CountDownLatch

@Suppress("TooManyFunctions")
internal class CheckoutWebView private constructor(
context: Context,
attributeSet: AttributeSet?,
Expand Down Expand Up @@ -67,6 +78,8 @@ internal class CheckoutWebView private constructor(
*/
internal var hasHandledTerminalFailure = false

private var checkoutRequestRetryReason: TelemetryNavigationRetryReason? = null
private var navigationTiming: NavigationTiming? = null
private val touchHandler = CheckoutWebViewTouchHandler()

/** Origin of the loaded checkout URL, trusted as a safe default for incoming-message validation. */
Expand Down Expand Up @@ -150,6 +163,8 @@ internal class CheckoutWebView private constructor(
)
checkoutRequest = request
didRetryCheckoutRequest = false
checkoutRequestRetryReason = null
navigationTiming = NavigationTiming(SystemClock.elapsedRealtime(), preloaded = isPreload)
loadCheckoutRequest(request)
}
}
Expand All @@ -171,13 +186,44 @@ internal class CheckoutWebView private constructor(
private fun resetCheckoutRequestRetryState() {
checkoutRequest = null
didRetryCheckoutRequest = false
checkoutRequestRetryReason = null
}

private data class CheckoutRequest(
val url: String,
val headers: Map<String, String>,
)

private data class NavigationTiming(
val startedAtMillis: Long,
val preloaded: Boolean,
)

internal fun recordTerminalProtocolFailureTelemetry() {
CheckoutTelemetry.recorder.recordError(
TelemetryErrorMetric(
category = TelemetryErrorCategory.Protocol,
stage = TelemetryErrorStage.Message,
code = TelemetryErrorCode.Unknown,
retryable = false,
isRetry = didRetryCheckoutRequest,
),
)
recordNavigationDuration(TelemetryNavigationDurationResult.Failure)
}

private fun recordNavigationDuration(result: TelemetryNavigationDurationResult) {
val timing = navigationTiming ?: return
navigationTiming = null
CheckoutTelemetry.recorder.recordNavigationDuration(
TelemetryNavigationDurationMetric(
milliseconds = (SystemClock.elapsedRealtime() - timing.startedAtMillis).toDouble(),
result = result,
preloaded = timing.preloaded,
),
)
}

internal fun markPreloadConsumed() {
isPreloadRequest = false
}
Expand All @@ -201,6 +247,17 @@ internal class CheckoutWebView private constructor(
)
if (wasBackgroundedUnconsumedPreload || !shouldDeliverLifecycleFailure) return true

CheckoutTelemetry.recorder.recordError(
TelemetryErrorMetric(
category = TelemetryErrorCategory.RenderProcess,
stage = TelemetryErrorStage.Presentation,
code = TelemetryErrorCode.Unknown,
retryable = false,
isRetry = didRetryCheckoutRequest,
),
)
recordNavigationDuration(TelemetryNavigationDurationResult.Failure)

// didCrash is API 26; framework delivery of this callback also begins on API 26.
val didCrash = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) detail.didCrash() else null
log.e(
Expand All @@ -225,6 +282,7 @@ internal class CheckoutWebView private constructor(
loadComplete = true
preloadCache.transition(this@CheckoutWebView, PreloadState.Ready)
listener.onCheckoutViewLoadComplete()
recordNavigationDuration(TelemetryNavigationDurationResult.Success)
resetCheckoutRequestRetryState()
}

Expand All @@ -236,6 +294,13 @@ internal class CheckoutWebView private constructor(
if (shouldRetryCheckoutRequest(request, error)) {
val checkoutRequest = requireNotNull(checkoutRequest)
didRetryCheckoutRequest = true
checkoutRequestRetryReason = CheckoutTelemetry.retryReason(error?.errorCode ?: 0)
CheckoutTelemetry.recorder.recordNavigationRetry(
TelemetryNavigationRetryMetric(
reason = requireNotNull(checkoutRequestRetryReason),
result = TelemetryNavigationRetryResult.Started,
),
)
log.w(
LOG_TAG,
"Retrying checkout navigation. Error code: ${error?.errorCode}, " +
Expand All @@ -247,6 +312,14 @@ internal class CheckoutWebView private constructor(

val isMainFrame = request?.isForMainFrame == true
if (isMainFrame) {
if (didRetryCheckoutRequest) {
CheckoutTelemetry.recorder.recordNavigationRetry(
TelemetryNavigationRetryMetric(
reason = checkoutRequestRetryReason ?: TelemetryNavigationRetryReason.Unknown,
result = TelemetryNavigationRetryResult.Failed,
),
)
}
preloadCache.evict(
PreloadState.Failed(
PreloadState.FailureReason.NavigationFailed,
Expand All @@ -260,6 +333,16 @@ internal class CheckoutWebView private constructor(
handleClientError(request, it)
}
if (isMainFrame) {
CheckoutTelemetry.recorder.recordError(
TelemetryErrorMetric(
category = TelemetryErrorCategory.Navigation,
stage = TelemetryErrorStage.Load,
code = CheckoutTelemetry.errorCode(error?.errorCode ?: 0),
retryable = error?.errorCode in RETRYABLE_CHECKOUT_ERROR_CODES,
isRetry = didRetryCheckoutRequest,
),
)
recordNavigationDuration(TelemetryNavigationDurationResult.Failure)
resetCheckoutRequestRetryState()
}
}
Expand All @@ -272,6 +355,20 @@ internal class CheckoutWebView private constructor(
val isMainFrame = request?.isForMainFrame == true
if (isMainFrame) {
val statusCode = errorResponse?.statusCode ?: 0
CheckoutTelemetry.recorder.recordError(
TelemetryErrorMetric(
category = TelemetryErrorCategory.Http,
stage = TelemetryErrorStage.Load,
code = when (statusCode) {
in 400..499 -> TelemetryErrorCode.Client
in 500..599 -> TelemetryErrorCode.Server
else -> TelemetryErrorCode.Unknown
},
retryable = statusCode >= 500,
isRetry = didRetryCheckoutRequest,
),
)
recordNavigationDuration(TelemetryNavigationDurationResult.Failure)
preloadCache.evict(
PreloadState.Failed(
PreloadState.FailureReason.HttpError(statusCode),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ public data class Configuration internal constructor(
var preloading: Preloading = Preloading(),
var title: String? = null,
var allowedMessageOrigins: Set<String> = emptySet(),
var telemetry: Telemetry = Telemetry(),
)

/**
Expand All @@ -35,6 +36,12 @@ public data class Preloading(
public val enabled: Boolean = true,
)

/** Controls anonymous diagnostic metrics sent by Checkout Kit. */
public data class Telemetry(
/** Set to false to prevent Checkout Kit from recording or sending diagnostic metrics. */
public val enabled: Boolean = true,
)

public enum class LogLevel {
DEBUG, WARN, ERROR, NONE
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ package com.shopify.checkoutkit

import androidx.core.net.toUri
import com.shopify.checkoutkit.ShopifyCheckoutKit.log
import com.shopify.checkoutkit.telemetry.TelemetryDecodeFailureType
import com.shopify.checkoutkit.telemetry.TelemetryProtocolDecodeErrorMetric
import com.shopify.checkoutkit.telemetry.TelemetryProtocolMethod
import com.shopify.ucp.embedded.checkout.InstrumentsChangeResultUcp
import com.shopify.ucp.embedded.checkout.ReadyResult
import com.shopify.ucp.embedded.checkout.UCPCheckoutResponseSchemaStatus
Expand Down Expand Up @@ -155,9 +158,16 @@ internal class EmbeddedCheckoutProtocolBridge(
}
} catch (e: SerializationException) {
log.d(LOG_TAG, "Failed to decode ECP message: $e raw=$message")
val isTerminalError = runCatching {
Json.parseToJsonElement(message).jsonObject["method"]?.jsonPrimitive?.content == CheckoutProtocol.error.method
}.getOrDefault(false)
val decodedMethod = runCatching {
Json.parseToJsonElement(message).jsonObject["method"]?.jsonPrimitive?.content
}.getOrNull()
CheckoutTelemetry.recorder.recordProtocolDecodeError(
TelemetryProtocolDecodeErrorMetric(
method = TelemetryProtocolMethod.fromMethod(decodedMethod.orEmpty()),
failureType = TelemetryDecodeFailureType.Envelope,
),
)
val isTerminalError = decodedMethod == CheckoutProtocol.error.method
if (isTerminalError) {
handleTerminalError(message, null)
} else {
Expand Down Expand Up @@ -245,6 +255,7 @@ internal class EmbeddedCheckoutProtocolBridge(
) {
return@onMainThread
}
view.recordTerminalProtocolFailureTelemetry()
view.listener.onCheckoutViewFailedWithError(failure)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,11 @@ public object ShopifyCheckoutKit {
*/
@JvmStatic
public fun configure(setter: ConfigurationUpdater) {
val wasTelemetryEnabled = configuration.telemetry.enabled
setter.configure(configuration)
if (wasTelemetryEnabled && !configuration.telemetry.enabled) {
CheckoutTelemetry.disable()
}
CheckoutWebView.clearCache()
}

Expand Down
Loading
Loading