From e36641b8fbc4163e421f8e0c2327f93adddf23c2 Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Wed, 19 Aug 2026 15:00:34 +0200 Subject: [PATCH] Add internal Checkout Kit telemetry foundations --- .ci/changed-file-filters.yml | 8 + .github/workflows/android-publish.yml | 3 +- .github/workflows/ci.yml | 15 + .github/workflows/telemetry-test.yml | 37 ++ .swiftlint.yml | 2 + Package.swift | 16 +- ShopifyCheckoutKit.podspec | 1 + dev.yml | 51 ++ platforms/android/lib/build.gradle | 1 - .../telemetry/CheckoutKitTelemetry.kt | 150 +++++ .../checkoutkit/telemetry/OtlpExporter.kt | 476 +++++++++++++++ .../telemetry/CheckoutKitTelemetryTest.kt | 441 ++++++++++++++ .../scripts/publish_android_snapshot | 7 +- platforms/web/package.json | 1 + platforms/web/pnpm-lock.yaml | 287 ++++----- platforms/web/pnpm-workspace.yaml | 1 + telemetry/README.md | 56 ++ telemetry/contract/metrics.md | 84 +++ .../CheckoutKitTelemetry.swift | 560 ++++++++++++++++++ .../CheckoutKitTelemetryTests.swift | 308 ++++++++++ telemetry/languages/typescript/package.json | 24 + telemetry/languages/typescript/src/client.ts | 266 +++++++++ telemetry/languages/typescript/src/index.ts | 11 + telemetry/languages/typescript/src/otlp.ts | 157 +++++ .../typescript/src/protocol-method.ts | 9 + telemetry/languages/typescript/src/types.ts | 121 ++++ .../languages/typescript/test/client.test.ts | 357 +++++++++++ telemetry/languages/typescript/tsconfig.json | 18 + 28 files changed, 3326 insertions(+), 142 deletions(-) create mode 100644 .github/workflows/telemetry-test.yml create mode 100644 platforms/android/lib/src/main/java/com/shopify/checkoutkit/telemetry/CheckoutKitTelemetry.kt create mode 100644 platforms/android/lib/src/main/java/com/shopify/checkoutkit/telemetry/OtlpExporter.kt create mode 100644 platforms/android/lib/src/test/java/com/shopify/checkoutkit/telemetry/CheckoutKitTelemetryTest.kt create mode 100644 telemetry/README.md create mode 100644 telemetry/contract/metrics.md create mode 100644 telemetry/languages/swift/Sources/CheckoutKitTelemetry/CheckoutKitTelemetry.swift create mode 100644 telemetry/languages/swift/Tests/CheckoutKitTelemetryTests/CheckoutKitTelemetryTests.swift create mode 100644 telemetry/languages/typescript/package.json create mode 100644 telemetry/languages/typescript/src/client.ts create mode 100644 telemetry/languages/typescript/src/index.ts create mode 100644 telemetry/languages/typescript/src/otlp.ts create mode 100644 telemetry/languages/typescript/src/protocol-method.ts create mode 100644 telemetry/languages/typescript/src/types.ts create mode 100644 telemetry/languages/typescript/test/client.test.ts create mode 100644 telemetry/languages/typescript/tsconfig.json diff --git a/.ci/changed-file-filters.yml b/.ci/changed-file-filters.yml index 8a71a60b7..d9d149431 100644 --- a/.ci/changed-file-filters.yml +++ b/.ci/changed-file-filters.yml @@ -6,6 +6,7 @@ android: - &exclude-docs-directory '!**/docs/**' swift: - 'platforms/swift/**' + - 'telemetry/languages/swift/**' - *exclude-markdown - *exclude-docs-directory reactNative: @@ -14,8 +15,15 @@ reactNative: - *exclude-docs-directory web: - 'platforms/web/**' + - 'telemetry/languages/typescript/**' + - 'telemetry/contract/**' - *exclude-markdown - *exclude-docs-directory +# Markdown stays included here: the metrics contract is a markdown document +# and contract changes must run the telemetry checks. +telemetry: + - 'telemetry/**' + - *exclude-docs-directory protocol: - 'protocol/**' - *exclude-markdown diff --git a/.github/workflows/android-publish.yml b/.github/workflows/android-publish.yml index 52d26aed6..0ea8637ca 100644 --- a/.github/workflows/android-publish.yml +++ b/.github/workflows/android-publish.yml @@ -69,8 +69,7 @@ jobs: - name: Publish Package run: | - ./gradlew \ - :lib:publishReleasePublicationToOssrh-staging-apiRepository + ./gradlew :lib:publishReleasePublicationToOssrh-staging-apiRepository env: OSSRH_USERNAME: ${{ secrets.OSSRH_USERNAME }} OSSRH_PASSWORD: ${{ secrets.OSSRH_PASSWORD }} diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dbf5df6ec..147c3e337 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -35,6 +35,7 @@ jobs: reactNativeIos: ${{ steps.platform.outputs.reactNative == 'true' || steps.platform.outputs.protocolTypescript == 'true' || steps.platform.outputs.protocolShared == 'true' || steps.infra.outputs.reactNativeCommon == 'true' || steps.infra.outputs.reactNativeIos == 'true' }} reactNativeAndroid: ${{ steps.platform.outputs.reactNative == 'true' || steps.platform.outputs.protocolTypescript == 'true' || steps.platform.outputs.protocolShared == 'true' || steps.infra.outputs.reactNativeCommon == 'true' || steps.infra.outputs.reactNativeAndroid == 'true' }} web: ${{ steps.platform.outputs.web == 'true' || steps.platform.outputs.protocolTypescript == 'true' || steps.platform.outputs.protocolShared == 'true' || steps.infra.outputs.web == 'true' }} + telemetry: ${{ steps.platform.outputs.telemetry == 'true' || steps.infra.outputs.telemetry == 'true' }} protocol: ${{ steps.platform.outputs.protocolTypescript == 'true' || steps.platform.outputs.protocolShared == 'true' || steps.infra.outputs.protocol == 'true' }} scripts: ${{ steps.infra.outputs.scripts == 'true' }} steps: @@ -101,6 +102,11 @@ jobs: - '.github/actions/setup/**' - '.ci/changed-file-filters.yml' - '.github/workflows/ci.yml' + telemetry: + - '.github/workflows/telemetry-test.yml' + - '.github/actions/setup/**' + - '.ci/changed-file-filters.yml' + - '.github/workflows/ci.yml' protocol: - '.github/workflows/protocol-test.yml' - '.github/actions/setup/**' @@ -208,6 +214,14 @@ jobs: pull-requests: write uses: ./.github/workflows/web.yml + telemetry-test: + name: Telemetry + needs: changes + if: needs.changes.outputs.telemetry == 'true' + permissions: + contents: read + uses: ./.github/workflows/telemetry-test.yml + protocol-test: name: Protocol needs: changes @@ -265,6 +279,7 @@ jobs: - rn-check-packed-files - rn-lint - web + - telemetry-test - protocol-test - scripts-test - breaking-changes diff --git a/.github/workflows/telemetry-test.yml b/.github/workflows/telemetry-test.yml new file mode 100644 index 000000000..e2973074c --- /dev/null +++ b/.github/workflows/telemetry-test.yml @@ -0,0 +1,37 @@ +name: Telemetry + +on: + workflow_call: + workflow_dispatch: + +jobs: + test: + name: Typecheck, lint, test + runs-on: ubuntu-latest + timeout-minutes: 10 + permissions: + contents: read + defaults: + run: + # The TypeScript telemetry package is a member of the web pnpm + # workspace, so dependency install and filtered runs happen there. + working-directory: platforms/web + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + + - name: Setup Node.js, pnpm, and install dependencies + uses: ./.github/actions/setup + with: + node-version-file: platforms/web/package.json + cache-dependency-path: platforms/web/pnpm-lock.yaml + package-json-file: platforms/web/package.json + working-directory: platforms/web + + - name: Typecheck + run: pnpm --filter @shopify/checkout-kit-telemetry run typecheck + + - name: Lint + run: pnpm --filter @shopify/checkout-kit-telemetry run lint + + - name: Test + run: pnpm --filter @shopify/checkout-kit-telemetry run test diff --git a/.swiftlint.yml b/.swiftlint.yml index e59de8a02..4e5191563 100644 --- a/.swiftlint.yml +++ b/.swiftlint.yml @@ -11,6 +11,8 @@ disabled_rules: included: - platforms/swift/Sources - platforms/swift/Tests + - telemetry/languages/swift/Sources + - telemetry/languages/swift/Tests excluded: - platforms/swift/Samples diff --git a/Package.swift b/Package.swift index 5fdd36339..572440701 100644 --- a/Package.swift +++ b/Package.swift @@ -21,7 +21,7 @@ let package = Package( .library( name: "EmbeddedCheckoutProtocol", targets: ["EmbeddedCheckoutProtocol"] - ) + ), ], dependencies: [ // Dependencies declare other packages that this package depends on. @@ -36,8 +36,13 @@ let package = Package( path: "protocol/languages/swift/Sources/UniversalCommerceProtocol/EmbeddedCheckoutProtocol" ), .target( - name: "ShopifyCheckoutKit", + name: "CheckoutKitTelemetry", dependencies: ["EmbeddedCheckoutProtocol"], + path: "telemetry/languages/swift/Sources/CheckoutKitTelemetry" + ), + .target( + name: "ShopifyCheckoutKit", + dependencies: ["EmbeddedCheckoutProtocol", "CheckoutKitTelemetry"], path: "platforms/swift/Sources/ShopifyCheckoutKit", resources: [.process("Assets.xcassets")] ), @@ -53,9 +58,14 @@ let package = Package( path: "protocol/languages/swift/Tests/EmbeddedCheckoutProtocolTests", resources: [.copy("Fixtures")] ), + .testTarget( + name: "CheckoutKitTelemetryTests", + dependencies: ["CheckoutKitTelemetry"], + path: "telemetry/languages/swift/Tests/CheckoutKitTelemetryTests" + ), .testTarget( name: "ShopifyCheckoutKitTests", - dependencies: ["ShopifyCheckoutKit"], + dependencies: ["ShopifyCheckoutKit", "CheckoutKitTelemetry"], path: "platforms/swift/Tests/ShopifyCheckoutKitTests" ), .testTarget( diff --git a/ShopifyCheckoutKit.podspec b/ShopifyCheckoutKit.podspec index 86de6289f..2dea003e6 100644 --- a/ShopifyCheckoutKit.podspec +++ b/ShopifyCheckoutKit.podspec @@ -28,6 +28,7 @@ Pod::Spec.new do |s| core.source_files = [ 'platforms/swift/Sources/ShopifyCheckoutKit/**/*.swift', 'protocol/languages/swift/Sources/UniversalCommerceProtocol/EmbeddedCheckoutProtocol/**/*.swift', + 'telemetry/languages/swift/Sources/CheckoutKitTelemetry/**/*.swift', ] core.resource_bundles = { 'ShopifyCheckoutKit' => ['platforms/swift/Sources/ShopifyCheckoutKit/Assets.xcassets'] diff --git a/dev.yml b/dev.yml index 08378e016..5b55e8e7d 100644 --- a/dev.yml +++ b/dev.yml @@ -136,6 +136,7 @@ commands: /opt/dev/bin/dev react-native lint /opt/dev/bin/dev web lint /opt/dev/bin/dev protocol lint + /opt/dev/bin/dev telemetry typecheck test: desc: Run tests across all supported workspaces @@ -146,6 +147,7 @@ commands: /opt/dev/bin/dev react-native test /opt/dev/bin/dev web test /opt/dev/bin/dev protocol test typescript + /opt/dev/bin/dev telemetry test typescript apollo: desc: "Apollo GraphQL schema and code generation commands" @@ -237,6 +239,55 @@ commands: desc: Verify protocol codegen tools match protocol/package.json run: ./protocol/scripts/check_codegen_tools.sh "$@" + telemetry: + desc: "Checkout telemetry package commands" + subcommands: + test: + desc: "Run telemetry tests for every language, or a single one. Usage: dev telemetry test [typescript|swift|kotlin]" + syntax: "[typescript|swift|kotlin]" + run: | + set -e + run_typescript() { (cd telemetry/languages/typescript && pnpm test); } + run_swift() { + # xcode_run only auto-discovers test targets under + # platforms/swift/Tests, so pass the telemetry target/suite + # pair explicitly. + (cd platforms/swift && ./Scripts/xcode_run test ShopifyCheckoutKit-Package CheckoutKitTelemetryTests/CheckoutKitTelemetryTests) + } + run_kotlin() { + platforms/android/gradlew -p platforms/android :lib:testDebugUnitTest \ + --tests "com.shopify.checkoutkit.telemetry.*" \ + --tests "com.shopify.checkoutkit.Telemetry*" \ + --console=plain + } + case "${1:-all}" in + typescript|ts) run_typescript ;; + swift|ios) run_swift ;; + kotlin|android) run_kotlin ;; + all) run_typescript; run_swift; run_kotlin ;; + *) echo "Usage: dev telemetry test [typescript|swift|kotlin]"; exit 1 ;; + esac + typecheck: + desc: "Type-check the TypeScript telemetry package" + run: cd telemetry/languages/typescript && pnpm typecheck + lint: + desc: "Lint the TypeScript and Swift telemetry sources (Kotlin lives in the Android lib and is covered by dev android lint)" + run: | + set -e + (cd telemetry/languages/typescript && pnpm lint) + # SwiftLint's scope comes from .swiftlint.yml's `included`, which + # covers the telemetry Swift sources. Use the Mintfile-pinned + # binary so results match CI. + swiftlint_bin="$(cd platforms/swift && mint which swiftlint)" + "$swiftlint_bin" lint --strict --no-cache --quiet + check: + desc: "Type-check, lint, and test the telemetry packages in every language" + run: | + set -e + /opt/dev/bin/dev telemetry typecheck + /opt/dev/bin/dev telemetry lint + /opt/dev/bin/dev telemetry test + # Android android: aliases: ["kotlin"] diff --git a/platforms/android/lib/build.gradle b/platforms/android/lib/build.gradle index c5f3f47ee..f812ac732 100644 --- a/platforms/android/lib/build.gradle +++ b/platforms/android/lib/build.gradle @@ -121,7 +121,6 @@ dependencies { mockitoAgent libs.mockito.core api project(':embedded-checkout-protocol') - testImplementation libs.junit testImplementation libs.robolectric testImplementation libs.mockito.core diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/telemetry/CheckoutKitTelemetry.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/telemetry/CheckoutKitTelemetry.kt new file mode 100644 index 000000000..b76fdce4c --- /dev/null +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/telemetry/CheckoutKitTelemetry.kt @@ -0,0 +1,150 @@ +package com.shopify.checkoutkit.telemetry + +import com.shopify.ucp.embedded.checkout.EmbeddedCheckoutProtocol + +internal enum class TelemetryErrorCategory(internal val wireValue: String) { + Http("http"), + Navigation("navigation"), + Protocol("protocol"), + RenderProcess("render_process"), + Unknown("unknown"), +} + +internal enum class TelemetryErrorStage(internal val wireValue: String) { + Initialization("initialization"), + Load("load"), + Message("message"), + Presentation("presentation"), +} + +internal enum class TelemetryErrorCode(internal val wireValue: String) { + Client("4xx"), + Server("5xx"), + Cancelled("cancelled"), + ConnectionLost("connection_lost"), + CannotConnect("cannot_connect"), + Dns("dns"), + Timeout("timeout"), + Unknown("unknown"), +} + +internal class TelemetryProtocolMethod private constructor(internal val wireValue: String) { + companion object { + fun fromMethod(method: String): TelemetryProtocolMethod = + TelemetryProtocolMethod( + if (method in EmbeddedCheckoutProtocol.Event.all) method else "unknown", + ) + } +} + +internal enum class TelemetryDecodeFailureType(internal val wireValue: String) { + Envelope("envelope"), + Params("params"), + Serialization("serialization"), + Unknown("unknown"), +} + +internal enum class TelemetryNavigationRetryReason(internal val wireValue: String) { + Timeout("timeout"), + ConnectionLost("connection_lost"), + CannotConnect("cannot_connect"), + Dns("dns"), + Unknown("unknown"), +} + +internal enum class TelemetryNavigationRetryResult(internal val wireValue: String) { + Started("started"), + Failed("failed"), + NotAttempted("not_attempted"), +} + +internal enum class TelemetryNavigationDurationResult(internal val wireValue: String) { + Success("success"), + Failure("failure"), +} + +internal enum class TelemetryProduct(internal val wireValue: String) { + CheckoutKit("checkout_kit"), + AcceleratedCheckouts("accelerated_checkouts"), + CustomerAuth("customer_auth"), +} + +internal enum class TelemetryPlatform(internal val wireValue: String) { + Android("android"), + ReactNativeAndroid("react-native-android"), +} + +internal data class TelemetryErrorMetric( + val category: TelemetryErrorCategory, + val stage: TelemetryErrorStage, + val code: TelemetryErrorCode, + val retryable: Boolean, + val isRetry: Boolean = false, +) + +internal data class TelemetryProtocolDecodeErrorMetric( + val method: TelemetryProtocolMethod, + val failureType: TelemetryDecodeFailureType, +) + +internal data class TelemetryNavigationRetryMetric( + val reason: TelemetryNavigationRetryReason, + val result: TelemetryNavigationRetryResult, +) + +internal data class TelemetryNavigationDurationMetric( + val milliseconds: Double, + val result: TelemetryNavigationDurationResult, + val preloaded: Boolean, +) + +internal data class CheckoutKitTelemetryConfiguration( + val sdkVersion: String, + val product: TelemetryProduct = TelemetryProduct.CheckoutKit, + val platform: TelemetryPlatform = TelemetryPlatform.Android, + val endpoint: String = CheckoutKitTelemetry.PRODUCTION_ENDPOINT, + val exportIntervalMillis: Long = DEFAULT_EXPORT_INTERVAL_MILLIS, + val maxPendingMeasurements: Int = DEFAULT_MAX_PENDING_MEASUREMENTS, +) + +internal class CheckoutKitTelemetry( + sdkVersion: String, + product: TelemetryProduct = TelemetryProduct.CheckoutKit, + platform: TelemetryPlatform = TelemetryPlatform.Android, +) { + private val exporter = OtlpExporter( + CheckoutKitTelemetryConfiguration( + sdkVersion = sdkVersion, + product = product, + platform = platform, + ), + ) + + fun start(): Unit = exporter.start() + + fun recordError(metric: TelemetryErrorMetric): Unit = exporter.recordError(metric) + + fun recordProtocolDecodeError(metric: TelemetryProtocolDecodeErrorMetric): Unit = + exporter.recordProtocolDecodeError(metric) + + fun recordNavigationRetry(metric: TelemetryNavigationRetryMetric): Unit = + exporter.recordNavigationRetry(metric) + + fun recordNavigationDuration(metric: TelemetryNavigationDurationMetric): Unit = + exporter.recordNavigationDuration(metric) + + fun flush(completion: (Boolean) -> Unit = {}): Unit = exporter.flush(completion) + + fun shutdown( + discardPending: Boolean = false, + completion: (Boolean) -> Unit = {}, + ): Unit = exporter.shutdown(discardPending, completion) + + companion object { + const val PRODUCTION_ENDPOINT: String = + "https://otlp-http-production.shopifysvc.com/v1/metrics" + } +} + +private const val DEFAULT_EXPORT_INTERVAL_MILLIS = 60_000L +private const val DEFAULT_MAX_PENDING_MEASUREMENTS = 128 diff --git a/platforms/android/lib/src/main/java/com/shopify/checkoutkit/telemetry/OtlpExporter.kt b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/telemetry/OtlpExporter.kt new file mode 100644 index 000000000..38d83e9b3 --- /dev/null +++ b/platforms/android/lib/src/main/java/com/shopify/checkoutkit/telemetry/OtlpExporter.kt @@ -0,0 +1,476 @@ +package com.shopify.checkoutkit.telemetry + +import kotlinx.serialization.json.JsonArray +import kotlinx.serialization.json.JsonElement +import kotlinx.serialization.json.JsonObject +import kotlinx.serialization.json.JsonPrimitive +import java.net.HttpURLConnection +import java.net.URI +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.RejectedExecutionException +import java.util.concurrent.ScheduledExecutorService +import java.util.concurrent.ScheduledFuture +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import kotlin.math.min + +internal class OtlpExporter( + private val configuration: CheckoutKitTelemetryConfiguration, + private val clock: () -> Long = { System.currentTimeMillis() * NANOS_PER_MILLISECOND }, + private val transport: TelemetryTransport = HttpTelemetryTransport(), + private val executor: ScheduledExecutorService = defaultExecutor(), + private val exportClockMillis: () -> Long = System::currentTimeMillis, +) { + private val lock = Any() + private val measurements = mutableListOf() + private var scheduledFlush: ScheduledFuture<*>? = null + private var consecutiveExportFailures = 0 + private var nextExportAllowedAtMillis = 0L + private val pendingFlushes = mutableSetOf() + private var stopped = false + + fun start() { + if (configuration.exportIntervalMillis <= 0) return + synchronized(lock) { + if (stopped || scheduledFlush != null) return + scheduledFlush = try { + executor.scheduleWithFixedDelay( + { flush() }, + configuration.exportIntervalMillis, + configuration.exportIntervalMillis, + TimeUnit.MILLISECONDS, + ) + } catch (_: RejectedExecutionException) { + null + } + } + } + + fun recordError(metric: TelemetryErrorMetric) { + recordCounter( + "checkout_kit_error", + attributes( + "category" to AttributeValue.StringValue(metric.category.wireValue), + "stage" to AttributeValue.StringValue(metric.stage.wireValue), + "code" to AttributeValue.StringValue(metric.code.wireValue), + "retryable" to AttributeValue.BooleanValue(metric.retryable), + "is_retry" to AttributeValue.BooleanValue(metric.isRetry), + ), + ) + } + + fun recordProtocolDecodeError(metric: TelemetryProtocolDecodeErrorMetric) { + recordCounter( + "checkout_kit_protocol_decode_error", + attributes( + "method" to AttributeValue.StringValue(metric.method.wireValue), + "failure_type" to AttributeValue.StringValue(metric.failureType.wireValue), + ), + ) + } + + fun recordNavigationRetry(metric: TelemetryNavigationRetryMetric) { + recordCounter( + "checkout_kit_navigation_retry", + attributes( + "reason" to AttributeValue.StringValue(metric.reason.wireValue), + "result" to AttributeValue.StringValue(metric.result.wireValue), + ), + ) + } + + fun recordNavigationDuration(metric: TelemetryNavigationDurationMetric) { + if (!metric.milliseconds.isFinite() || metric.milliseconds < 0) return + record( + Measurement( + type = MeasurementType.Histogram, + name = "checkout_kit_navigation_duration_ms", + value = metric.milliseconds, + unit = "ms", + attributes = attributes( + "result" to AttributeValue.StringValue(metric.result.wireValue), + "preloaded" to AttributeValue.BooleanValue(metric.preloaded), + ), + timeUnixNano = clock(), + ), + ) + } + + fun flush(completion: (Boolean) -> Unit = {}) = flush(ignoreBackoff = false, completion) + + private fun flush(ignoreBackoff: Boolean, completion: (Boolean) -> Unit) { + val operation = FlushOperation(completion) + val shouldSubmit = synchronized(lock) { + if (stopped) { + false + } else { + pendingFlushes += operation + true + } + } + if (!shouldSubmit) { + operation.complete(false) + return + } + try { + val future = executor.submit { + runFlush(ignoreBackoff, operation) + } + var cancelled = false + synchronized(lock) { + operation.future = future + if (stopped) { + pendingFlushes -= operation + future.cancel(true) + cancelled = true + } + } + if (cancelled) operation.complete(false) + } catch (_: RejectedExecutionException) { + synchronized(lock) { pendingFlushes -= operation } + operation.complete(false) + } + } + + fun shutdown(discardPending: Boolean = false, completion: (Boolean) -> Unit = {}) { + var cancelledFlushes = emptyList() + val alreadyStopped = synchronized(lock) { + if (stopped) return@synchronized true + scheduledFlush?.cancel(false) + scheduledFlush = null + if (discardPending) { + stopped = true + measurements.clear() + cancelledFlushes = pendingFlushes.toList() + pendingFlushes.clear() + cancelledFlushes.forEach { + it.cancel() + it.future?.cancel(true) + } + } + false + } + if (alreadyStopped) { + completion(true) + return + } + if (discardPending) { + transport.cancel() + executor.shutdownNow() + cancelledFlushes.forEach { it.complete(false) } + completion(true) + return + } + flush(ignoreBackoff = true) { + synchronized(lock) { stopped = true } + completion(it) + executor.shutdown() + } + } + + private fun runFlush(ignoreBackoff: Boolean, operation: FlushOperation) { + try { + val pending = synchronized(lock) { + if (stopped || (!ignoreBackoff && exportClockMillis() < nextExportAllowedAtMillis)) { + null + } else { + measurements.toList().also { measurements.clear() } + } + } + val succeeded = when { + pending == null -> false + pending.isEmpty() -> true + operation.isCancelled() -> false + else -> { + val body = OtlpPayload.build(configuration, pending) + val exported = !operation.isCancelled() && runCatching { + transport.post(configuration.endpoint, body, operation::isCancelled) + }.getOrDefault(false) + synchronized(lock) { + if (!stopped) { + if (!exported) restorePendingMeasurementsLocked(pending) + updateExportBackoffLocked(exported) + } + } + exported + } + } + operation.complete(succeeded) + } finally { + synchronized(lock) { pendingFlushes -= operation } + } + } + + private fun recordCounter(name: String, attributes: Map) { + record( + Measurement( + type = MeasurementType.Counter, + name = name, + value = 1.0, + unit = null, + attributes = attributes, + timeUnixNano = clock(), + ), + ) + } + + private fun attributes(vararg values: Pair): Map = mapOf( + *values, + "product" to AttributeValue.StringValue(configuration.product.wireValue), + "platform" to AttributeValue.StringValue(configuration.platform.wireValue), + ) + + private fun record(measurement: Measurement) { + synchronized(lock) { + if (stopped) return + val capacity = configuration.maxPendingMeasurements.coerceAtLeast(1) + if (measurements.size < capacity) measurements += measurement + } + } + + private fun updateExportBackoffLocked(succeeded: Boolean) { + if (succeeded) { + consecutiveExportFailures = 0 + nextExportAllowedAtMillis = 0 + return + } + consecutiveExportFailures += 1 + val exponent = min(consecutiveExportFailures - 1, MAX_EXPORT_BACKOFF_EXPONENT) + val backoff = min( + configuration.exportIntervalMillis.coerceAtLeast(1) * (1L shl exponent), + MAX_EXPORT_BACKOFF_MILLIS, + ) + nextExportAllowedAtMillis = exportClockMillis() + backoff + } + + private fun restorePendingMeasurementsLocked(pending: List) { + val capacity = configuration.maxPendingMeasurements.coerceAtLeast(1) + val queuedDuringExport = measurements.toList() + measurements.clear() + measurements += (pending + queuedDuringExport).take(capacity) + } +} + +private class FlushOperation( + private val completion: (Boolean) -> Unit, +) { + var future: Future<*>? = null + + private val cancelled = AtomicBoolean(false) + private val completed = AtomicBoolean(false) + + fun cancel() { + cancelled.set(true) + } + + fun isCancelled(): Boolean = cancelled.get() || Thread.currentThread().isInterrupted + + fun complete(succeeded: Boolean) { + if (completed.compareAndSet(false, true)) completion(succeeded) + } +} + +internal fun interface TelemetryTransport { + fun post(endpoint: String, body: String): Boolean + + fun post(endpoint: String, body: String, isCancelled: () -> Boolean): Boolean = + if (isCancelled()) false else post(endpoint, body) + + fun cancel(): Unit = Unit +} + +private class HttpTelemetryTransport : TelemetryTransport { + private val lock = Any() + private val activeConnections = mutableSetOf() + + override fun post(endpoint: String, body: String): Boolean = post(endpoint, body) { false } + + override fun post(endpoint: String, body: String, isCancelled: () -> Boolean): Boolean { + var succeeded = false + val connection = URI(endpoint).toURL().openConnection() as HttpURLConnection + synchronized(lock) { activeConnections += connection } + try { + if (!isCancelled()) { + connection.requestMethod = "POST" + connection.connectTimeout = HTTP_TIMEOUT_MILLIS + connection.readTimeout = HTTP_TIMEOUT_MILLIS + connection.setRequestProperty("Content-Type", "application/json") + connection.doOutput = true + } + if (!isCancelled()) { + connection.outputStream.bufferedWriter(Charsets.UTF_8).use { it.write(body) } + succeeded = connection.responseCode in HTTP_SUCCESS_MIN until HTTP_SUCCESS_MAX_EXCLUSIVE + } + } finally { + synchronized(lock) { activeConnections -= connection } + connection.disconnect() + } + return succeeded + } + + override fun cancel() { + synchronized(lock) { activeConnections.toList() }.forEach { it.disconnect() } + } +} + +private object OtlpPayload { + fun build(configuration: CheckoutKitTelemetryConfiguration, measurements: List): String { + val metrics = measurements.groupBy { GroupKey(it.type, it.name, it.attributes) } + .values + .sortedBy { it.first().name } + .map(::metric) + val resource = jsonObject( + "attributes" to attributes( + mapOf( + "service.name" to AttributeValue.StringValue("checkout-kit"), + "service.version" to AttributeValue.StringValue(configuration.sdkVersion), + "telemetry.sdk.language" to AttributeValue.StringValue(TELEMETRY_SDK_LANGUAGE), + "telemetry.sdk.name" to AttributeValue.StringValue(INSTRUMENTATION_NAME), + "telemetry.sdk.version" to AttributeValue.StringValue(configuration.sdkVersion), + ), + ), + ) + val scope = jsonObject( + "name" to JsonPrimitive(INSTRUMENTATION_NAME), + "version" to JsonPrimitive(configuration.sdkVersion), + ) + val scopeMetric = jsonObject("scope" to scope, "metrics" to JsonArray(metrics)) + val resourceMetric = jsonObject("resource" to resource, "scopeMetrics" to JsonArray(listOf(scopeMetric))) + return jsonObject("resourceMetrics" to JsonArray(listOf(resourceMetric))).toString() + } + + private fun metric(group: List): JsonObject { + val first = group.first() + val startTime = group.first().timeUnixNano.toString() + val endTime = group.last().timeUnixNano.toString() + return if (first.type == MeasurementType.Counter) { + counterMetric(first, group.size, startTime, endTime) + } else { + histogramMetric(first, group.map { it.value }, startTime, endTime) + } + } + + private fun counterMetric( + measurement: Measurement, + count: Int, + startTime: String, + endTime: String, + ): JsonObject { + val point = jsonObject( + "attributes" to attributes(measurement.attributes), + "asInt" to JsonPrimitive(count.toString()), + "startTimeUnixNano" to JsonPrimitive(startTime), + "timeUnixNano" to JsonPrimitive(endTime), + ) + val sum = jsonObject( + "aggregationTemporality" to JsonPrimitive(OTLP_DELTA_TEMPORALITY), + "isMonotonic" to JsonPrimitive(true), + "dataPoints" to JsonArray(listOf(point)), + ) + return jsonObject("name" to JsonPrimitive(measurement.name), "sum" to sum) + } + + private fun histogramMetric( + measurement: Measurement, + values: List, + startTime: String, + endTime: String, + ): JsonObject { + val bucketCounts = MutableList(HISTOGRAM_BOUNDS.size + 1) { 0L } + values.forEach { value -> + val found = HISTOGRAM_BOUNDS.indexOfFirst { value <= it } + bucketCounts[if (found < 0) bucketCounts.lastIndex else found] += 1 + } + val point = jsonObject( + "attributes" to attributes(measurement.attributes), + "bucketCounts" to JsonArray(bucketCounts.map { JsonPrimitive(it.toString()) }), + "count" to JsonPrimitive(values.size.toString()), + "explicitBounds" to JsonArray(HISTOGRAM_BOUNDS.map(::JsonPrimitive)), + "min" to JsonPrimitive(values.min()), + "max" to JsonPrimitive(values.max()), + "sum" to JsonPrimitive(values.sum()), + "startTimeUnixNano" to JsonPrimitive(startTime), + "timeUnixNano" to JsonPrimitive(endTime), + ) + val histogram = jsonObject( + "aggregationTemporality" to JsonPrimitive(OTLP_DELTA_TEMPORALITY), + "dataPoints" to JsonArray(listOf(point)), + ) + return jsonObject( + "name" to JsonPrimitive(measurement.name), + "unit" to JsonPrimitive(measurement.unit ?: ""), + "histogram" to histogram, + ) + } + + private fun attributes(values: Map): JsonArray = JsonArray( + values.toSortedMap().map { (key, value) -> + val encodedValue = when (value) { + is AttributeValue.BooleanValue -> jsonObject("boolValue" to JsonPrimitive(value.value)) + is AttributeValue.StringValue -> jsonObject("stringValue" to JsonPrimitive(value.value)) + } + jsonObject("key" to JsonPrimitive(key), "value" to encodedValue) + }, + ) + + private fun jsonObject(vararg values: Pair): JsonObject = JsonObject(mapOf(*values)) +} + +private enum class MeasurementType { + Counter, + Histogram, +} + +private sealed interface AttributeValue { + data class StringValue(val value: String) : AttributeValue + data class BooleanValue(val value: Boolean) : AttributeValue +} + +private data class Measurement( + val type: MeasurementType, + val name: String, + val value: Double, + val unit: String?, + val attributes: Map, + val timeUnixNano: Long, +) + +private data class GroupKey( + val type: MeasurementType, + val name: String, + val attributes: Map, +) + +private fun defaultExecutor(): ScheduledExecutorService = + Executors.newSingleThreadScheduledExecutor { runnable -> + Thread(runnable, "ShopifyCheckoutKit-Telemetry").apply { isDaemon = true } + } + +private const val MAX_EXPORT_BACKOFF_MILLIS = 15 * 60_000L +private const val MAX_EXPORT_BACKOFF_EXPONENT = 4 +private const val HTTP_TIMEOUT_MILLIS = 5_000 +private const val HTTP_SUCCESS_MIN = 200 +private const val HTTP_SUCCESS_MAX_EXCLUSIVE = 300 +private const val NANOS_PER_MILLISECOND = 1_000_000L +private const val OTLP_DELTA_TEMPORALITY = 1 +private const val INSTRUMENTATION_NAME = "checkout-kit-telemetry" +private const val TELEMETRY_SDK_LANGUAGE = "java" +private const val HISTOGRAM_BOUND_100_MS = 100.0 +private const val HISTOGRAM_BOUND_250_MS = 250.0 +private const val HISTOGRAM_BOUND_500_MS = 500.0 +private const val HISTOGRAM_BOUND_1_SECOND = 1_000.0 +private const val HISTOGRAM_BOUND_2_5_SECONDS = 2_500.0 +private const val HISTOGRAM_BOUND_5_SECONDS = 5_000.0 +private const val HISTOGRAM_BOUND_10_SECONDS = 10_000.0 +private const val HISTOGRAM_BOUND_30_SECONDS = 30_000.0 +private val HISTOGRAM_BOUNDS = listOf( + HISTOGRAM_BOUND_100_MS, + HISTOGRAM_BOUND_250_MS, + HISTOGRAM_BOUND_500_MS, + HISTOGRAM_BOUND_1_SECOND, + HISTOGRAM_BOUND_2_5_SECONDS, + HISTOGRAM_BOUND_5_SECONDS, + HISTOGRAM_BOUND_10_SECONDS, + HISTOGRAM_BOUND_30_SECONDS, +) diff --git a/platforms/android/lib/src/test/java/com/shopify/checkoutkit/telemetry/CheckoutKitTelemetryTest.kt b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/telemetry/CheckoutKitTelemetryTest.kt new file mode 100644 index 000000000..5e666c419 --- /dev/null +++ b/platforms/android/lib/src/test/java/com/shopify/checkoutkit/telemetry/CheckoutKitTelemetryTest.kt @@ -0,0 +1,441 @@ +package com.shopify.checkoutkit.telemetry + +import org.assertj.core.api.Assertions.assertThat +import org.junit.Test +import java.util.concurrent.CountDownLatch +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicBoolean +import java.util.concurrent.atomic.AtomicInteger + +class CheckoutKitTelemetryTest { + @Test + fun `aggregates counters with closed attributes`() { + val request = RecordedRequest() + val times = ArrayDeque(listOf(1_000_000L, 2_000_000L)) + val executor = Executors.newSingleThreadScheduledExecutor() + val telemetry = OtlpExporter( + configuration = CheckoutKitTelemetryConfiguration( + sdkVersion = "1.2.3", + product = TelemetryProduct.AcceleratedCheckouts, + platform = TelemetryPlatform.ReactNativeAndroid, + ), + clock = { times.removeFirst() }, + transport = TelemetryTransport { endpoint, body -> + request.endpoint = endpoint + request.body = body + true + }, + executor = executor, + ) + val metric = TelemetryErrorMetric( + TelemetryErrorCategory.Http, + TelemetryErrorStage.Load, + TelemetryErrorCode.Server, + retryable = true, + isRetry = true, + ) + + telemetry.recordError(metric) + telemetry.recordError(metric) + val result = flush(telemetry) + + assertThat(result).isTrue() + assertThat(request.endpoint).isEqualTo(CheckoutKitTelemetry.PRODUCTION_ENDPOINT) + assertThat(request.body).contains("\"checkout_kit_error\"") + assertThat(request.body).contains("\"asInt\":\"2\"") + assertThat(request.body).contains( + "\"key\":\"service.name\",\"value\":{\"stringValue\":\"checkout-kit\"}", + "\"key\":\"service.version\",\"value\":{\"stringValue\":\"1.2.3\"}", + "\"key\":\"telemetry.sdk.language\",\"value\":{\"stringValue\":\"java\"}", + "\"key\":\"telemetry.sdk.name\",\"value\":{\"stringValue\":\"checkout-kit-telemetry\"}", + "\"key\":\"telemetry.sdk.version\",\"value\":{\"stringValue\":\"1.2.3\"}", + "\"key\":\"platform\",\"value\":{\"stringValue\":\"react-native-android\"}", + "\"key\":\"product\",\"value\":{\"stringValue\":\"accelerated_checkouts\"}", + "\"key\":\"is_retry\",\"value\":{\"boolValue\":true}", + ) + assertThat(request.body).doesNotContain("\"key\":\"integration\"") + assertThat(request.body).doesNotContain("checkoutUrl", "error.message") + executor.shutdownNow() + } + + @Test + fun `bounds pending measurements and isolates export failure`() { + val executor = Executors.newSingleThreadScheduledExecutor() + val exportAttempts = AtomicInteger() + val telemetry = OtlpExporter( + configuration = CheckoutKitTelemetryConfiguration( + sdkVersion = "1.2.3", + maxPendingMeasurements = 1, + ), + clock = { 1 }, + transport = TelemetryTransport { _, _ -> + exportAttempts.incrementAndGet() + error("unavailable") + }, + executor = executor, + exportClockMillis = { 1_000 }, + ) + + telemetry.recordNavigationRetry( + TelemetryNavigationRetryMetric( + TelemetryNavigationRetryReason.Timeout, + TelemetryNavigationRetryResult.Started, + ), + ) + telemetry.recordNavigationRetry( + TelemetryNavigationRetryMetric( + TelemetryNavigationRetryReason.Dns, + TelemetryNavigationRetryResult.Failed, + ), + ) + + assertThat(flush(telemetry)).isFalse() + telemetry.recordNavigationRetry( + TelemetryNavigationRetryMetric( + TelemetryNavigationRetryReason.Timeout, + TelemetryNavigationRetryResult.Failed, + ), + ) + assertThat(flush(telemetry)).isFalse() + assertThat(exportAttempts).hasValue(1) + executor.shutdownNow() + } + + @Test + fun `records finite non-negative durations only`() { + val request = RecordedRequest() + val executor = Executors.newSingleThreadScheduledExecutor() + val telemetry = OtlpExporter( + configuration = CheckoutKitTelemetryConfiguration(sdkVersion = "1.2.3"), + clock = { 1 }, + transport = TelemetryTransport { _, body -> + request.body = body + true + }, + executor = executor, + ) + + telemetry.recordNavigationDuration( + TelemetryNavigationDurationMetric(Double.NaN, TelemetryNavigationDurationResult.Failure, false), + ) + telemetry.recordNavigationDuration( + TelemetryNavigationDurationMetric(450.0, TelemetryNavigationDurationResult.Success, false), + ) + + assertThat(flush(telemetry)).isTrue() + assertThat(request.body).contains("\"count\":\"1\"", "\"sum\":450.0") + executor.shutdownNow() + } + + @Test + fun `shutdown bypasses export backoff`() { + val executor = Executors.newSingleThreadScheduledExecutor() + val exportAttempts = AtomicInteger() + val request = RecordedRequest() + val telemetry = OtlpExporter( + configuration = CheckoutKitTelemetryConfiguration(sdkVersion = "1.2.3"), + transport = TelemetryTransport { _, body -> + request.body = body + exportAttempts.incrementAndGet() > 1 + }, + executor = executor, + exportClockMillis = { 1_000 }, + ) + val metric = TelemetryErrorMetric( + TelemetryErrorCategory.Http, + TelemetryErrorStage.Load, + TelemetryErrorCode.Server, + retryable = true, + ) + telemetry.recordError(metric) + assertThat(flush(telemetry)).isFalse() + telemetry.recordError(metric) + + val latch = CountDownLatch(1) + var result = false + telemetry.shutdown { + result = it + latch.countDown() + } + + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue() + assertThat(result).isTrue() + assertThat(exportAttempts).hasValue(2) + assertThat(request.body).contains("\"asInt\":\"2\"") + } + + @Test + fun `accepts methods added to the generated protocol catalog`() { + val request = RecordedRequest() + val executor = Executors.newSingleThreadScheduledExecutor() + val telemetry = OtlpExporter( + configuration = CheckoutKitTelemetryConfiguration(sdkVersion = "1.2.3"), + transport = TelemetryTransport { _, body -> + request.body = body + true + }, + executor = executor, + ) + + telemetry.recordProtocolDecodeError( + TelemetryProtocolDecodeErrorMetric( + TelemetryProtocolMethod.fromMethod("ec.buyer.change"), + TelemetryDecodeFailureType.Params, + ), + ) + + assertThat(flush(telemetry)).isTrue() + assertThat(request.body).contains("ec.buyer.change") + executor.shutdownNow() + } + + @Test + fun `discard shutdown cancels transport and isolates later lifecycle calls`() { + val executor = Executors.newSingleThreadScheduledExecutor() + val cancelled = AtomicBoolean() + val transport = object : TelemetryTransport { + override fun post(endpoint: String, body: String): Boolean { + while (!cancelled.get()) Thread.yield() + return false + } + + override fun cancel() { + cancelled.set(true) + } + } + val telemetry = OtlpExporter( + configuration = CheckoutKitTelemetryConfiguration(sdkVersion = "1.2.3"), + clock = { 1 }, + transport = transport, + executor = executor, + ) + telemetry.recordError( + TelemetryErrorMetric( + TelemetryErrorCategory.Http, + TelemetryErrorStage.Load, + TelemetryErrorCode.Server, + retryable = true, + ), + ) + telemetry.flush() + + var shutdownResult = false + telemetry.shutdown(discardPending = true) { shutdownResult = it } + telemetry.start() + + assertThat(shutdownResult).isTrue() + assertThat(cancelled).isTrue() + assertThat(flush(telemetry)).isFalse() + telemetry.shutdown { assertThat(it).isTrue() } + } + + @Test + fun `discard shutdown completes active and queued flush callbacks`() { + val executor = Executors.newSingleThreadScheduledExecutor() + val cancelled = AtomicBoolean() + val exportStarted = CountDownLatch(1) + val transport = object : TelemetryTransport { + override fun post(endpoint: String, body: String): Boolean { + exportStarted.countDown() + while (!cancelled.get()) Thread.yield() + return false + } + + override fun cancel() { + cancelled.set(true) + } + } + val telemetry = OtlpExporter( + configuration = CheckoutKitTelemetryConfiguration(sdkVersion = "1.2.3"), + clock = { 1 }, + transport = transport, + executor = executor, + ) + telemetry.recordError( + TelemetryErrorMetric( + TelemetryErrorCategory.Http, + TelemetryErrorStage.Load, + TelemetryErrorCode.Server, + retryable = true, + ), + ) + val activeFlushLatch = CountDownLatch(1) + var activeFlushResult = true + telemetry.flush { + activeFlushResult = it + activeFlushLatch.countDown() + } + assertThat(exportStarted.await(5, TimeUnit.SECONDS)).isTrue() + + val queuedFlushLatch = CountDownLatch(1) + var queuedFlushResult = true + telemetry.flush { + queuedFlushResult = it + queuedFlushLatch.countDown() + } + val shutdownLatch = CountDownLatch(1) + var shutdownResult = false + telemetry.shutdown(discardPending = true) { + shutdownResult = it + shutdownLatch.countDown() + } + + assertThat(shutdownLatch.await(5, TimeUnit.SECONDS)).isTrue() + assertThat(activeFlushLatch.await(5, TimeUnit.SECONDS)).isTrue() + assertThat(queuedFlushLatch.await(5, TimeUnit.SECONDS)).isTrue() + assertThat(shutdownResult).isTrue() + assertThat(activeFlushResult).isFalse() + assertThat(queuedFlushResult).isFalse() + } + + @Test + fun `discard shutdown prevents upload after measurements are drained`() { + val executor = Executors.newSingleThreadScheduledExecutor() + val postEntered = CountDownLatch(1) + val cancelled = CountDownLatch(1) + val uploadStarted = AtomicBoolean() + val transport = object : TelemetryTransport { + override fun post(endpoint: String, body: String): Boolean = error("unused") + + override fun post(endpoint: String, body: String, isCancelled: () -> Boolean): Boolean { + postEntered.countDown() + cancelled.await(5, TimeUnit.SECONDS) + if (!isCancelled()) uploadStarted.set(true) + return false + } + + override fun cancel() { + cancelled.countDown() + } + } + val telemetry = OtlpExporter( + configuration = CheckoutKitTelemetryConfiguration(sdkVersion = "1.2.3"), + clock = { 1 }, + transport = transport, + executor = executor, + ) + telemetry.recordError( + TelemetryErrorMetric( + TelemetryErrorCategory.Http, + TelemetryErrorStage.Load, + TelemetryErrorCode.Server, + retryable = true, + ), + ) + val flushLatch = CountDownLatch(1) + var flushResult = true + telemetry.flush { + flushResult = it + flushLatch.countDown() + } + assertThat(postEntered.await(5, TimeUnit.SECONDS)).isTrue() + + val shutdownLatch = CountDownLatch(1) + var shutdownResult = false + telemetry.shutdown(discardPending = true) { + shutdownResult = it + shutdownLatch.countDown() + } + + assertThat(shutdownLatch.await(5, TimeUnit.SECONDS)).isTrue() + assertThat(flushLatch.await(5, TimeUnit.SECONDS)).isTrue() + assertThat(shutdownResult).isTrue() + assertThat(flushResult).isFalse() + assertThat(uploadStarted).isFalse() + } + + @Test + fun `default transport cancellation state is scoped per exporter`() { + val firstExecutor = Executors.newSingleThreadScheduledExecutor() + val secondExecutor = Executors.newSingleThreadScheduledExecutor() + val firstTelemetry = OtlpExporter( + configuration = CheckoutKitTelemetryConfiguration(sdkVersion = "1.2.3"), + executor = firstExecutor, + ) + val secondTelemetry = OtlpExporter( + configuration = CheckoutKitTelemetryConfiguration(sdkVersion = "1.2.3"), + executor = secondExecutor, + ) + + try { + assertThat(transportOf(firstTelemetry)).isNotSameAs(transportOf(secondTelemetry)) + } finally { + firstExecutor.shutdownNow() + secondExecutor.shutdownNow() + } + } + + @Test + fun `queued flush respects backoff created by active export failure`() { + val executor = Executors.newSingleThreadScheduledExecutor() + val exportAttempts = AtomicInteger() + val exportStarted = CountDownLatch(1) + val releaseExport = CountDownLatch(1) + val telemetry = OtlpExporter( + configuration = CheckoutKitTelemetryConfiguration(sdkVersion = "1.2.3"), + clock = { 1 }, + transport = TelemetryTransport { _, _ -> + exportAttempts.incrementAndGet() + exportStarted.countDown() + releaseExport.await(5, TimeUnit.SECONDS) + false + }, + executor = executor, + exportClockMillis = { 1_000 }, + ) + telemetry.recordError( + TelemetryErrorMetric( + TelemetryErrorCategory.Http, + TelemetryErrorStage.Load, + TelemetryErrorCode.Server, + retryable = true, + ), + ) + val activeFlushLatch = CountDownLatch(1) + var activeFlushResult = true + telemetry.flush { + activeFlushResult = it + activeFlushLatch.countDown() + } + assertThat(exportStarted.await(5, TimeUnit.SECONDS)).isTrue() + + val queuedFlushLatch = CountDownLatch(1) + var queuedFlushResult = true + telemetry.flush { + queuedFlushResult = it + queuedFlushLatch.countDown() + } + releaseExport.countDown() + + assertThat(activeFlushLatch.await(5, TimeUnit.SECONDS)).isTrue() + assertThat(queuedFlushLatch.await(5, TimeUnit.SECONDS)).isTrue() + assertThat(activeFlushResult).isFalse() + assertThat(queuedFlushResult).isFalse() + assertThat(exportAttempts).hasValue(1) + executor.shutdownNow() + } + + private fun flush(telemetry: OtlpExporter): Boolean { + val latch = CountDownLatch(1) + var result = false + telemetry.flush { + result = it + latch.countDown() + } + assertThat(latch.await(5, TimeUnit.SECONDS)).isTrue() + return result + } + + private fun transportOf(telemetry: OtlpExporter): Any { + val field = OtlpExporter::class.java.getDeclaredField("transport") + field.isAccessible = true + return requireNotNull(field.get(telemetry)) + } +} + +private class RecordedRequest { + @Volatile var endpoint: String? = null + + @Volatile var body: String? = null +} diff --git a/platforms/react-native/scripts/publish_android_snapshot b/platforms/react-native/scripts/publish_android_snapshot index 967d5016a..c295312ce 100755 --- a/platforms/react-native/scripts/publish_android_snapshot +++ b/platforms/react-native/scripts/publish_android_snapshot @@ -10,5 +10,8 @@ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" ANDROID_SDK_PATH="$SCRIPT_DIR/../../android" cd "$ANDROID_SDK_PATH" -./gradlew :embedded-checkout-protocol:publishToMavenLocal :lib:publishToMavenLocal -q -echo "publish_android_snapshot: published com.shopify:embedded-checkout-protocol and com.shopify:checkout-kit to ~/.m2/" +./gradlew \ + :embedded-checkout-protocol:publishToMavenLocal \ + :lib:publishToMavenLocal \ + -q +echo "publish_android_snapshot: published the protocol and Checkout Kit artifacts to ~/.m2/" diff --git a/platforms/web/package.json b/platforms/web/package.json index 1d018f0d4..d0a337480 100644 --- a/platforms/web/package.json +++ b/platforms/web/package.json @@ -75,6 +75,7 @@ "devDependencies": { "@custom-elements-manifest/analyzer": "^0.10.4", "@shopify/checkout-kit-protocol": "workspace:*", + "@shopify/checkout-kit-telemetry": "workspace:*", "@types/node": "^22.10.0", "@vitest/coverage-v8": "^4.1.0", "happy-dom": "^20.8.9", diff --git a/platforms/web/pnpm-lock.yaml b/platforms/web/pnpm-lock.yaml index 3a4255935..b9ebd17ac 100644 --- a/platforms/web/pnpm-lock.yaml +++ b/platforms/web/pnpm-lock.yaml @@ -14,6 +14,9 @@ importers: '@shopify/checkout-kit-protocol': specifier: workspace:* version: link:../../protocol/languages/typescript + '@shopify/checkout-kit-telemetry': + specifier: workspace:* + version: link:../../telemetry/languages/typescript '@types/node': specifier: ^22.10.0 version: 22.19.18 @@ -55,6 +58,22 @@ importers: specifier: ^5.9.2 version: 5.9.3 + ../../telemetry/languages/typescript: + dependencies: + '@shopify/checkout-kit-protocol': + specifier: workspace:* + version: link:../../../protocol/languages/typescript + devDependencies: + oxlint: + specifier: ^1.62.0 + version: 1.63.0 + typescript: + specifier: 5.9.3 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.10(@types/node@22.19.18)(@vitest/coverage-v8@4.1.10)(happy-dom@20.8.9)(vite@8.0.16(@types/node@22.19.18)(esbuild@0.28.1)) + packages: '@babel/helper-string-parser@7.29.7': @@ -90,166 +109,166 @@ packages: resolution: {integrity: sha512-2iVksJ156XuaeeC6jB6oMG6k9ROHS3W1delwJLL804yQMri9NnQW78JDCYMtFfW8b4locUG+3+hrtAHxk+fNGg==} '@emnapi/core@1.10.0': - resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==} + resolution: {integrity: sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==, tarball: https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz} '@emnapi/runtime@1.10.0': - resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==} + resolution: {integrity: sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==, tarball: https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz} '@emnapi/wasi-threads@1.2.1': - resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==} + resolution: {integrity: sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==, tarball: https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz} '@esbuild/aix-ppc64@0.28.1': - resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==} + resolution: {integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==, tarball: https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz} engines: {node: '>=18'} cpu: [ppc64] os: [aix] '@esbuild/android-arm64@0.28.1': - resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==} + resolution: {integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==, tarball: https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz} engines: {node: '>=18'} cpu: [arm64] os: [android] '@esbuild/android-arm@0.28.1': - resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==} + resolution: {integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==, tarball: https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz} engines: {node: '>=18'} cpu: [arm] os: [android] '@esbuild/android-x64@0.28.1': - resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==} + resolution: {integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==, tarball: https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz} engines: {node: '>=18'} cpu: [x64] os: [android] '@esbuild/darwin-arm64@0.28.1': - resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==} + resolution: {integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==, tarball: https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz} engines: {node: '>=18'} cpu: [arm64] os: [darwin] '@esbuild/darwin-x64@0.28.1': - resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==} + resolution: {integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==, tarball: https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz} engines: {node: '>=18'} cpu: [x64] os: [darwin] '@esbuild/freebsd-arm64@0.28.1': - resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==} + resolution: {integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==, tarball: https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz} engines: {node: '>=18'} cpu: [arm64] os: [freebsd] '@esbuild/freebsd-x64@0.28.1': - resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==} + resolution: {integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==, tarball: https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz} engines: {node: '>=18'} cpu: [x64] os: [freebsd] '@esbuild/linux-arm64@0.28.1': - resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==} + resolution: {integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==, tarball: https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz} engines: {node: '>=18'} cpu: [arm64] os: [linux] '@esbuild/linux-arm@0.28.1': - resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==} + resolution: {integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==, tarball: https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz} engines: {node: '>=18'} cpu: [arm] os: [linux] '@esbuild/linux-ia32@0.28.1': - resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==} + resolution: {integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==, tarball: https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz} engines: {node: '>=18'} cpu: [ia32] os: [linux] '@esbuild/linux-loong64@0.28.1': - resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==} + resolution: {integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==, tarball: https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz} engines: {node: '>=18'} cpu: [loong64] os: [linux] '@esbuild/linux-mips64el@0.28.1': - resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==} + resolution: {integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==, tarball: https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz} engines: {node: '>=18'} cpu: [mips64el] os: [linux] '@esbuild/linux-ppc64@0.28.1': - resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==} + resolution: {integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==, tarball: https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz} engines: {node: '>=18'} cpu: [ppc64] os: [linux] '@esbuild/linux-riscv64@0.28.1': - resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==} + resolution: {integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==, tarball: https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz} engines: {node: '>=18'} cpu: [riscv64] os: [linux] '@esbuild/linux-s390x@0.28.1': - resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==} + resolution: {integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==, tarball: https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz} engines: {node: '>=18'} cpu: [s390x] os: [linux] '@esbuild/linux-x64@0.28.1': - resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==} + resolution: {integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==, tarball: https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz} engines: {node: '>=18'} cpu: [x64] os: [linux] '@esbuild/netbsd-arm64@0.28.1': - resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==} + resolution: {integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==, tarball: https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz} engines: {node: '>=18'} cpu: [arm64] os: [netbsd] '@esbuild/netbsd-x64@0.28.1': - resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==} + resolution: {integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==, tarball: https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz} engines: {node: '>=18'} cpu: [x64] os: [netbsd] '@esbuild/openbsd-arm64@0.28.1': - resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==} + resolution: {integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==, tarball: https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz} engines: {node: '>=18'} cpu: [arm64] os: [openbsd] '@esbuild/openbsd-x64@0.28.1': - resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==} + resolution: {integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==, tarball: https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz} engines: {node: '>=18'} cpu: [x64] os: [openbsd] '@esbuild/openharmony-arm64@0.28.1': - resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==} + resolution: {integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==, tarball: https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz} engines: {node: '>=18'} cpu: [arm64] os: [openharmony] '@esbuild/sunos-x64@0.28.1': - resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==} + resolution: {integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==, tarball: https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz} engines: {node: '>=18'} cpu: [x64] os: [sunos] '@esbuild/win32-arm64@0.28.1': - resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==} + resolution: {integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==, tarball: https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz} engines: {node: '>=18'} cpu: [arm64] os: [win32] '@esbuild/win32-ia32@0.28.1': - resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==} + resolution: {integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==, tarball: https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz} engines: {node: '>=18'} cpu: [ia32] os: [win32] '@esbuild/win32-x64@0.28.1': - resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==} + resolution: {integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==, tarball: https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz} engines: {node: '>=18'} cpu: [x64] os: [win32] @@ -281,7 +300,7 @@ packages: resolution: {integrity: sha512-xgAyonlVVS+q7Vc7qLW0UrJU7rSFcETRWsqdXZtjzRU8dF+6CkozTK4V4y1LwOX7j8r/vHphjDeMeGI4tNGeGA==} '@napi-rs/wasm-runtime@1.1.5': - resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==} + resolution: {integrity: sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==, tarball: https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz} peerDependencies: '@emnapi/core': ^1.7.1 '@emnapi/runtime': ^1.7.1 @@ -302,245 +321,245 @@ packages: resolution: {integrity: sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==} '@oxfmt/binding-android-arm-eabi@0.47.0': - resolution: {integrity: sha512-KrMQRdMi/upr81qT4ijK6X6BNp6jqpMY7FwILQnwIy9QLc3qpnhUx5rsCLGzn4ewsCQ0CNAspN2ogmP1GXLyLw==} + resolution: {integrity: sha512-KrMQRdMi/upr81qT4ijK6X6BNp6jqpMY7FwILQnwIy9QLc3qpnhUx5rsCLGzn4ewsCQ0CNAspN2ogmP1GXLyLw==, tarball: https://registry.npmjs.org/@oxfmt/binding-android-arm-eabi/-/binding-android-arm-eabi-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] '@oxfmt/binding-android-arm64@0.47.0': - resolution: {integrity: sha512-r4ixS/PeUpAFKgrpDoZ5pSkthjZzVzKd95525Aazj+aOv9H4ulK5zYHGb7wFY5n5kZxHK8TbOJUZgoEb1ohddQ==} + resolution: {integrity: sha512-r4ixS/PeUpAFKgrpDoZ5pSkthjZzVzKd95525Aazj+aOv9H4ulK5zYHGb7wFY5n5kZxHK8TbOJUZgoEb1ohddQ==, tarball: https://registry.npmjs.org/@oxfmt/binding-android-arm64/-/binding-android-arm64-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] '@oxfmt/binding-darwin-arm64@0.47.0': - resolution: {integrity: sha512-CLWxiKpMl+195cm09CuaWEhJK0CirRkoMa07aR9+9AFPat2LfIKtwx1JqxZM0MTvcMe6+adlJNdVL6jdInvq3g==} + resolution: {integrity: sha512-CLWxiKpMl+195cm09CuaWEhJK0CirRkoMa07aR9+9AFPat2LfIKtwx1JqxZM0MTvcMe6+adlJNdVL6jdInvq3g==, tarball: https://registry.npmjs.org/@oxfmt/binding-darwin-arm64/-/binding-darwin-arm64-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] '@oxfmt/binding-darwin-x64@0.47.0': - resolution: {integrity: sha512-Xq5fjTYDC50faUeLSm0rZdBqoTgleXEdD7NpJdARtQIczkCJn3xNjMUSQQkUmh4CtxkKTNL68lytcOK3e/osgg==} + resolution: {integrity: sha512-Xq5fjTYDC50faUeLSm0rZdBqoTgleXEdD7NpJdARtQIczkCJn3xNjMUSQQkUmh4CtxkKTNL68lytcOK3e/osgg==, tarball: https://registry.npmjs.org/@oxfmt/binding-darwin-x64/-/binding-darwin-x64-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] '@oxfmt/binding-freebsd-x64@0.47.0': - resolution: {integrity: sha512-QOU9ZIJ52p5askcEC0QJvvr8trHAWoonul8bgISo6gYUL3s50zkqafBYcNAr9LJZQbsZtPfIWHk9+5+nUp1qJQ==} + resolution: {integrity: sha512-QOU9ZIJ52p5askcEC0QJvvr8trHAWoonul8bgISo6gYUL3s50zkqafBYcNAr9LJZQbsZtPfIWHk9+5+nUp1qJQ==, tarball: https://registry.npmjs.org/@oxfmt/binding-freebsd-x64/-/binding-freebsd-x64-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] '@oxfmt/binding-linux-arm-gnueabihf@0.47.0': - resolution: {integrity: sha512-oJxDM1aBhPvz9gmElBv8UpxyiqhwfjcbrSxT5F0xtuUzY6dQI27/AQPIt3eu3Z5Yvn0kQl5R7MA3Z+MbnRvCBw==} + resolution: {integrity: sha512-oJxDM1aBhPvz9gmElBv8UpxyiqhwfjcbrSxT5F0xtuUzY6dQI27/AQPIt3eu3Z5Yvn0kQl5R7MA3Z+MbnRvCBw==, tarball: https://registry.npmjs.org/@oxfmt/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] '@oxfmt/binding-linux-arm-musleabihf@0.47.0': - resolution: {integrity: sha512-g8Lh50VS4ibGz2q6v7r9UZY4D0dM16SdrFYOMzhqIoCwGcai8VMIRUAcqn1/jlCsOOzUXJ741+kCeJt0cofakQ==} + resolution: {integrity: sha512-g8Lh50VS4ibGz2q6v7r9UZY4D0dM16SdrFYOMzhqIoCwGcai8VMIRUAcqn1/jlCsOOzUXJ741+kCeJt0cofakQ==, tarball: https://registry.npmjs.org/@oxfmt/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] '@oxfmt/binding-linux-arm64-gnu@0.47.0': - resolution: {integrity: sha512-YrNT1vQ0asaXoRbrvYENPqmBfOQ9Xr8enPNOULeYfg44VjCcrUowFy5QZr+WawE0zyP8cH9e9Gxxg0fDEFzhcg==} + resolution: {integrity: sha512-YrNT1vQ0asaXoRbrvYENPqmBfOQ9Xr8enPNOULeYfg44VjCcrUowFy5QZr+WawE0zyP8cH9e9Gxxg0fDEFzhcg==, tarball: https://registry.npmjs.org/@oxfmt/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] '@oxfmt/binding-linux-arm64-musl@0.47.0': - resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==} + resolution: {integrity: sha512-IxtQC/sbBi4ubbY+MdwdanRWrG9InQJVZqyMsBa5IUaQcnSg86gQme574HxXMC1p4bo4YhV99zQ+wNnGCvEgzw==, tarball: https://registry.npmjs.org/@oxfmt/binding-linux-arm64-musl/-/binding-linux-arm64-musl-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] '@oxfmt/binding-linux-ppc64-gnu@0.47.0': - resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==} + resolution: {integrity: sha512-EWXEhOMbWO0q6eJSbu0QLkU8cKi0ljlYLngeDs2Ocu/pm1rrLwyQiYzlFbdnMRURI4w9ndr1sI9rSbhlJ5o23Q==, tarball: https://registry.npmjs.org/@oxfmt/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] '@oxfmt/binding-linux-riscv64-gnu@0.47.0': - resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==} + resolution: {integrity: sha512-tZrjS11TUiDuEpRaqdk8K9F9xETRyKXfuZKmdeW+Gj7coBnm7+8sBEfyt033EAFEQSlkniAXvBLh+Qja2ioGBQ==, tarball: https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] '@oxfmt/binding-linux-riscv64-musl@0.47.0': - resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==} + resolution: {integrity: sha512-KBFy+2CFKUCZzYwX2ZOPQKck1vjQbz+hextuc19G4r0WRJwadfAeuQMQRQvB+Ivc8brlbOVg7et8K7E467440g==, tarball: https://registry.npmjs.org/@oxfmt/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] '@oxfmt/binding-linux-s390x-gnu@0.47.0': - resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==} + resolution: {integrity: sha512-REUPFKVGSiK99B+9eaPhluEVglzaoj/SMykNC5SUiV2RSsBfV5lWN7Y0iCIc251Wz3GaeAGZsJ/zj3gjarxdFg==, tarball: https://registry.npmjs.org/@oxfmt/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] '@oxfmt/binding-linux-x64-gnu@0.47.0': - resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==} + resolution: {integrity: sha512-KVftVSVEDeIfRW3TIeLe3aNI/iY4m1fu5mDwHcisKMZSCMKLkrhFsjowC7o9RoqNPxbbglm2+/6KAKBIts2t0Q==, tarball: https://registry.npmjs.org/@oxfmt/binding-linux-x64-gnu/-/binding-linux-x64-gnu-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] '@oxfmt/binding-linux-x64-musl@0.47.0': - resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==} + resolution: {integrity: sha512-DTsmGEaA2860Aq5VUyDO8/MT9NFxwVL93RnRYmpMwK6DsSkThmvEpqoUDDljziEpAedMRG19SCogrNbINSbLUQ==, tarball: https://registry.npmjs.org/@oxfmt/binding-linux-x64-musl/-/binding-linux-x64-musl-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] '@oxfmt/binding-openharmony-arm64@0.47.0': - resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==} + resolution: {integrity: sha512-8r5BDro7fLOBoq1JXHLVSs55OlrxQhEso4HVo0TcY7OXJUPYfjPoOaYL5us+yIwqyP9rQwN+rxuiNFSmaxSuOQ==, tarball: https://registry.npmjs.org/@oxfmt/binding-openharmony-arm64/-/binding-openharmony-arm64-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] '@oxfmt/binding-win32-arm64-msvc@0.47.0': - resolution: {integrity: sha512-qtz/gzm8IjSPUlseZ0ofW8zyHLoZsuP5HTfcGGkWkUblB89JT8GNYH3ICqjbDsqsGqXum0/ZndXTFplSdXFIcg==} + resolution: {integrity: sha512-qtz/gzm8IjSPUlseZ0ofW8zyHLoZsuP5HTfcGGkWkUblB89JT8GNYH3ICqjbDsqsGqXum0/ZndXTFplSdXFIcg==, tarball: https://registry.npmjs.org/@oxfmt/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] '@oxfmt/binding-win32-ia32-msvc@0.47.0': - resolution: {integrity: sha512-5vIcdcIDE7nCx+MXN6sm8kbC4zajDB31E86rez4i45iHNH/2NjdKlJ720xcHTr3eeiMcttCGPHPhE1TjtBDGZw==} + resolution: {integrity: sha512-5vIcdcIDE7nCx+MXN6sm8kbC4zajDB31E86rez4i45iHNH/2NjdKlJ720xcHTr3eeiMcttCGPHPhE1TjtBDGZw==, tarball: https://registry.npmjs.org/@oxfmt/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] '@oxfmt/binding-win32-x64-msvc@0.47.0': - resolution: {integrity: sha512-Sr59Y5ms54ONBjxFeWhVlGyQcHXxcl9DxC23f6yXlRkcos7LXBLoO+KDfxexjHIOZh7cWqrWduzvUjJ+pHp8cQ==} + resolution: {integrity: sha512-Sr59Y5ms54ONBjxFeWhVlGyQcHXxcl9DxC23f6yXlRkcos7LXBLoO+KDfxexjHIOZh7cWqrWduzvUjJ+pHp8cQ==, tarball: https://registry.npmjs.org/@oxfmt/binding-win32-x64-msvc/-/binding-win32-x64-msvc-0.47.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] '@oxlint/binding-android-arm-eabi@1.63.0': - resolution: {integrity: sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==} + resolution: {integrity: sha512-A9xLtQt7i0OA1PoB/meog6kikXI9CdwEp7ZwQqmgnpKn3G3b1orvTDy8CQ6T7w1HvDrgWGB78PkFKcWgibcTCg==, tarball: https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [android] '@oxlint/binding-android-arm64@1.63.0': - resolution: {integrity: sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A==} + resolution: {integrity: sha512-SQo+ZMvdR9l3CxZp5W5gFNxSiDxclY6lOzzNpKYLF8asESpm3Pwumx0gER5T7aHLF1/2BAAtLD3DiDkdgy4V1A==, tarball: https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] '@oxlint/binding-darwin-arm64@1.63.0': - resolution: {integrity: sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g==} + resolution: {integrity: sha512-6W82XjJDTmMnjg30427l0dufpnyLoq7wEukKdM6/g2VIybRVuQiBVh43EA4b+UxZ3+tLcKm+Or/pXGNgLCEU8g==, tarball: https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] '@oxlint/binding-darwin-x64@1.63.0': - resolution: {integrity: sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw==} + resolution: {integrity: sha512-CnWd/YCuVG5W1BYkjJEVbJG11o526O9qAwBEQM+nh8K19CRFUkFdROXCyYkGmroHEYQe4vgQ6+lh3550Lp35Xw==, tarball: https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] '@oxlint/binding-freebsd-x64@1.63.0': - resolution: {integrity: sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA==} + resolution: {integrity: sha512-a4eZAqrmtajqcxfdAzC+l7g3PaE3V8hpAYqqeD3fTxLXOMFdK3eNTZrU80n4dDEVm0JXy1aL5PqvqWldBl6zYA==, tarball: https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] '@oxlint/binding-linux-arm-gnueabihf@1.63.0': - resolution: {integrity: sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w==} + resolution: {integrity: sha512-tYUtU9TdbU3uXF5D62g5zXJ13iniFGhXQx5vp9cyEjGdbSAY3VdFBSaldYvyoDmgMZ0ZYuwQP1Y4t2Fhejwa0w==, tarball: https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] '@oxlint/binding-linux-arm-musleabihf@1.63.0': - resolution: {integrity: sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg==} + resolution: {integrity: sha512-I5r3twFf776UZg9dmRo2xbrKt00tTkORXEVe0ctg4vdTkQvJAjiCHxnbAU2HL1AiJ9cqADA76MAliuilsAWnvg==, tarball: https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] '@oxlint/binding-linux-arm64-gnu@1.63.0': - resolution: {integrity: sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ==} + resolution: {integrity: sha512-t7ltUkg6FFh4b564QyGir8xIj/QZbXu8FlcRkcyW9+ztr/mfRHlvUOFd95pJCXi9s/L5DrUeWWgpXRS+V+6igQ==, tarball: https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] '@oxlint/binding-linux-arm64-musl@1.63.0': - resolution: {integrity: sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==} + resolution: {integrity: sha512-Q5mmZy/XWjuYFUuQyYjOvZ5U/JkKEwnpir6hGxhh6HcdP0V/BKxLo8dqkfF/t7r7AguB17dfS/8+go5AQDRR6g==, tarball: https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] '@oxlint/binding-linux-ppc64-gnu@1.63.0': - resolution: {integrity: sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==} + resolution: {integrity: sha512-uBGtuZ0TzLB4x5wVa82HGNvYqY8buwDhyCnCP0R0gkk9szqVsP0MeTtD5HX7EsEuFIt+aYmYxuxeVxs3nTSwtQ==, tarball: https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] '@oxlint/binding-linux-riscv64-gnu@1.63.0': - resolution: {integrity: sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==} + resolution: {integrity: sha512-h4s6FwxE+9MeA181o0dnDwHP32Y/bG8EiB/vrD6Ib+AMt6haigDc/0bUtI/sLmQDBMJnUfaCmtSSrEAqjtEVrA==, tarball: https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [glibc] '@oxlint/binding-linux-riscv64-musl@1.63.0': - resolution: {integrity: sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==} + resolution: {integrity: sha512-2EaNcCBR8Mcjl5ARtuN3BdEpVkX7KpjSjMGZ/mJMIeaXgTtdz5ytg2VwygMSStA/k0ixfvZFoZOfjDEcouV5vQ==, tarball: https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [riscv64] os: [linux] libc: [musl] '@oxlint/binding-linux-s390x-gnu@1.63.0': - resolution: {integrity: sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==} + resolution: {integrity: sha512-p4hlf/fd7TrYYl3QrWWD0GocqJefwMu3cHQhmi2FvEB/YOvFb5DZN3SMBaPi7B1TM5DeypkEtrVib674q1KKPg==, tarball: https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] '@oxlint/binding-linux-x64-gnu@1.63.0': - resolution: {integrity: sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==} + resolution: {integrity: sha512-Vgq9rkRVcPcjbcH+ihYTfpeR7vCXfqpd+z5ItTGc0yYUV59L5ceHYN1iV4H9bKGV7Rn5hkVc7x3mSvHegduENA==, tarball: https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] '@oxlint/binding-linux-x64-musl@1.63.0': - resolution: {integrity: sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==} + resolution: {integrity: sha512-3/Lkq/ncooA61rorrC+ZQed1Bc4VpGj+WnGsp58zmxKgvZ2vhreu+dcVyr3mX8NUpq7mfZ4gDDTou/yrF1Pd7A==, tarball: https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] '@oxlint/binding-openharmony-arm64@1.63.0': - resolution: {integrity: sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==} + resolution: {integrity: sha512-0/EdD/6hDkx5Mfd769PTjvEM8mZ/6Dfukp1dBCL/2PjlIVGEtYdNZyok6ChqYPsT9JcFnlQnUeQzO0/1L/oC9w==, tarball: https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] '@oxlint/binding-win32-arm64-msvc@1.63.0': - resolution: {integrity: sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg==} + resolution: {integrity: sha512-wb0CUkN8ngwPiRQBjD1Cj0LsHeNvm+Xt6YBHDMtj2DVQVD6Oj8Ri7g6BD+KICf6LaBqZlmzOvy6nF9E/8yyGOg==, tarball: https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] '@oxlint/binding-win32-ia32-msvc@1.63.0': - resolution: {integrity: sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w==} + resolution: {integrity: sha512-BX5iq+ovdNlVYhSn5qPMUIT0uwAwt2lmEnCnzK+Gkhw4DovIvhGb96OFhV8yzQNUnQxn/xGkOR+X+BLrLDNm8w==, tarball: https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ia32] os: [win32] '@oxlint/binding-win32-x64-msvc@1.63.0': - resolution: {integrity: sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA==} + resolution: {integrity: sha512-QeN/WELOfsXMeYwxvfgQrl6CbVftYUCZsGXHjXQd5Trccm8+i4gmtxaOui4xbJQaiDlviF8F3yLSBloQUeFsfA==, tarball: https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.63.0.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -550,96 +569,96 @@ packages: engines: {node: '>=18'} '@rolldown/binding-android-arm64@1.0.3': - resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==} + resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==, tarball: https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [android] '@rolldown/binding-darwin-arm64@1.0.3': - resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==} + resolution: {integrity: sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==, tarball: https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [darwin] '@rolldown/binding-darwin-x64@1.0.3': - resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==} + resolution: {integrity: sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==, tarball: https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [darwin] '@rolldown/binding-freebsd-x64@1.0.3': - resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==} + resolution: {integrity: sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==, tarball: https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [freebsd] '@rolldown/binding-linux-arm-gnueabihf@1.0.3': - resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==} + resolution: {integrity: sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm] os: [linux] '@rolldown/binding-linux-arm64-gnu@1.0.3': - resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==} + resolution: {integrity: sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [glibc] '@rolldown/binding-linux-arm64-musl@1.0.3': - resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==} + resolution: {integrity: sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [linux] libc: [musl] '@rolldown/binding-linux-ppc64-gnu@1.0.3': - resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==} + resolution: {integrity: sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [ppc64] os: [linux] libc: [glibc] '@rolldown/binding-linux-s390x-gnu@1.0.3': - resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==} + resolution: {integrity: sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [s390x] os: [linux] libc: [glibc] '@rolldown/binding-linux-x64-gnu@1.0.3': - resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==} + resolution: {integrity: sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [glibc] '@rolldown/binding-linux-x64-musl@1.0.3': - resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==} + resolution: {integrity: sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==, tarball: https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [linux] libc: [musl] '@rolldown/binding-openharmony-arm64@1.0.3': - resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==} + resolution: {integrity: sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==, tarball: https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [openharmony] '@rolldown/binding-wasm32-wasi@1.0.3': - resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==} + resolution: {integrity: sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==, tarball: https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] '@rolldown/binding-win32-arm64-msvc@1.0.3': - resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==} + resolution: {integrity: sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==, tarball: https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [arm64] os: [win32] '@rolldown/binding-win32-x64-msvc@1.0.3': - resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==} + resolution: {integrity: sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==, tarball: https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz} engines: {node: ^20.19.0 || >=22.12.0} cpu: [x64] os: [win32] @@ -657,140 +676,140 @@ packages: optional: true '@rollup/rollup-android-arm-eabi@4.61.1': - resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==} + resolution: {integrity: sha512-JnBB8MdXj45cajvTuO5FmPlvFVJRQgvrz1uSEl3NwqFnReAPGwb8EanbGi4z2nRaqLzjJSv5/JmycoTKlRZxHA==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.61.1.tgz} cpu: [arm] os: [android] '@rollup/rollup-android-arm64@4.61.1': - resolution: {integrity: sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==} + resolution: {integrity: sha512-Jx2g7iSjw4AOT0HDPHM9RV3GNjRXwybWtSFZiZAYUTjUwjVrYIwq3kBf+LnhqJlzXFAqTAh2F7IGI+O568exPw==, tarball: https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.61.1.tgz} cpu: [arm64] os: [android] '@rollup/rollup-darwin-arm64@4.61.1': - resolution: {integrity: sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==} + resolution: {integrity: sha512-0F1L/Z3Eqv8mT2n3dCpeO8GcTvHvVqkP5/t6DMsn0KzhYVcg+s7Ncl5DS8qjKYEeio6Az0Gt6nyBORay5qIlCA==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.61.1.tgz} cpu: [arm64] os: [darwin] '@rollup/rollup-darwin-x64@4.61.1': - resolution: {integrity: sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==} + resolution: {integrity: sha512-qLttcH871ujY4YcVfUSShhOw+CsoTatYz8gRbHO7Bb92QH059/P0y5do1KMs41fY0BpD2x4AJH/gID0zFiqVKQ==, tarball: https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.61.1.tgz} cpu: [x64] os: [darwin] '@rollup/rollup-freebsd-arm64@4.61.1': - resolution: {integrity: sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==} + resolution: {integrity: sha512-fUI4RapGE0Oh3mb8mgfvC1O2nU1RpDZUKnDQm3xB1Ipg7C2wTs5Kstz7G2uWK99a8S2yTMq8/P4uycwNa0nJyw==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.61.1.tgz} cpu: [arm64] os: [freebsd] '@rollup/rollup-freebsd-x64@4.61.1': - resolution: {integrity: sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==} + resolution: {integrity: sha512-H5YrdvJaDtI/U9/emrD4b++xkvp3y/JvOe4rizHbxvkyMfRS/CiRYdji+Pl8D0brEaNFWUh1drQxgAGIl6Xudw==, tarball: https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.61.1.tgz} cpu: [x64] os: [freebsd] '@rollup/rollup-linux-arm-gnueabihf@4.61.1': - resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==} + resolution: {integrity: sha512-Q8CBCCQtDFrYtXoeUXSrnFXKOnyUhx6bz+SkL6A0E7V8kAiCJ5pamq1WtbfpVGhR5TSpXY6ak3avmDc5fHTyJA==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.61.1.tgz} cpu: [arm] os: [linux] libc: [glibc] '@rollup/rollup-linux-arm-musleabihf@4.61.1': - resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==} + resolution: {integrity: sha512-nwnhk1581l0FBVellGcVCAT0Oi06onEA3WB53sf01VO3I0UPBkMH9sXONYME2K0ovXcNayJfNtHfm6mpJElatQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.61.1.tgz} cpu: [arm] os: [linux] libc: [musl] '@rollup/rollup-linux-arm64-gnu@4.61.1': - resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==} + resolution: {integrity: sha512-x5Xr49hwt3hdW75UOZm3395YwwzPyauktslv29KpWL/T+vVAzoT3azLcTWv0eMciBNrx+DYjH4paehHoLpPvpg==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.61.1.tgz} cpu: [arm64] os: [linux] libc: [glibc] '@rollup/rollup-linux-arm64-musl@4.61.1': - resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==} + resolution: {integrity: sha512-unMS3H73DpaoPyyEVPjGKleM/s0mkmsauTENpw4INQY8y4+IuLNjkueQ5QCtC0D3N38Y38yhAU8OoZ20S2Tm6w==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.61.1.tgz} cpu: [arm64] os: [linux] libc: [musl] '@rollup/rollup-linux-loong64-gnu@4.61.1': - resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==} + resolution: {integrity: sha512-zNZzGRnAhwjFEYmvphJRV5XaQGjs62cCmeYYHUT//NbvEnHauw+I85nGG+SiVg5ld4GX8D1IbKIX+ozITQnhMQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.61.1.tgz} cpu: [loong64] os: [linux] libc: [glibc] '@rollup/rollup-linux-loong64-musl@4.61.1': - resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==} + resolution: {integrity: sha512-LdpWGL8X209B2SIvWjqlc8VZgM6PKfontSerGepuldQmHYrAOtnMCXeJkxXGbC+PPZVOuu5czJo7fNV6aeW8rQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.61.1.tgz} cpu: [loong64] os: [linux] libc: [musl] '@rollup/rollup-linux-ppc64-gnu@4.61.1': - resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==} + resolution: {integrity: sha512-EC5kTtNaNGOmbMGqar8dvJy6y/hg99GAwjfBz++pxZhQATXGcRjd6c5en5wcbru0vkRmiMGsQKdMJOOf6sza4g==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.61.1.tgz} cpu: [ppc64] os: [linux] libc: [glibc] '@rollup/rollup-linux-ppc64-musl@4.61.1': - resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==} + resolution: {integrity: sha512-8hiwp6D4acEcNK78I4rP0/XtS1sknWIAMJBPdR4l6zUtyTm5KiTDr5bXmWt4foY7nAN7AThDHgkLIEZOWKbzWw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.61.1.tgz} cpu: [ppc64] os: [linux] libc: [musl] '@rollup/rollup-linux-riscv64-gnu@4.61.1': - resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==} + resolution: {integrity: sha512-10dh/h/BqA7DuMPWSxkR8uks18FRwnwOEqr5zOTEl+NOwP/OMzKX8OFR/Of9xxDA7D5qef1Nzar5WDD2kCCr1g==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.61.1.tgz} cpu: [riscv64] os: [linux] libc: [glibc] '@rollup/rollup-linux-riscv64-musl@4.61.1': - resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==} + resolution: {integrity: sha512-YKJ5lg35DP17gcAOggnihe+APw9HLyj1Xn7gsmGumBJAUDa6NGXNixJzmkWLhcK9TOuuyQjdamzvJefkO7qHZQ==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.61.1.tgz} cpu: [riscv64] os: [linux] libc: [musl] '@rollup/rollup-linux-s390x-gnu@4.61.1': - resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==} + resolution: {integrity: sha512-Mlil5G2Jj6a7B3LWGctg+XPL9vdXYuzCtNXfxOQ0nPjc2m6ueUktocPGH9bnAM0bNRKb/bAWTujUU7IJQdQA+g==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.61.1.tgz} cpu: [s390x] os: [linux] libc: [glibc] '@rollup/rollup-linux-x64-gnu@4.61.1': - resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==} + resolution: {integrity: sha512-bVWIOIk6pV01p4CdUbPP7CJ/434z+OooYjDuFcR+44N35YvKUC66G8MGnvcWx5mWKW3g61J+t74l3Kj15Kwn2Q==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.61.1.tgz} cpu: [x64] os: [linux] libc: [glibc] '@rollup/rollup-linux-x64-musl@4.61.1': - resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==} + resolution: {integrity: sha512-qy5pBvZbqNFheBz61R1rzsezjm0J7O2oNGoWtGoY89SZYLUfxAJTBAqDChqAIdB4rCiIbi9nF7yZ83GnNiLwSw==, tarball: https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.61.1.tgz} cpu: [x64] os: [linux] libc: [musl] '@rollup/rollup-openbsd-x64@4.61.1': - resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==} + resolution: {integrity: sha512-E83TXjI4zm0+5f2qO+UOudaCYIhYwpJ5jq6YCZNIZ+6CbfhKrkAGezeiASBL9ElxAxFsRS9ZhESv8mfnj6TKeg==, tarball: https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.61.1.tgz} cpu: [x64] os: [openbsd] '@rollup/rollup-openharmony-arm64@4.61.1': - resolution: {integrity: sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==} + resolution: {integrity: sha512-fbWnKqVkjrJN38vNe3ahkbk6iejS/3b0Nt7EEtPpE6RBacZcGXNKbzfHN3GUUlXOPghUg0j6XUGrtjX9z1sIvA==, tarball: https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.61.1.tgz} cpu: [arm64] os: [openharmony] '@rollup/rollup-win32-arm64-msvc@4.61.1': - resolution: {integrity: sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==} + resolution: {integrity: sha512-ArMl38iVAbk0New1ogihQNY6iphLi4ZaRsa037gUzv5yeKPY8TD3Dmy4x2RNC1VztU/uqm+G+/RwFrSka3Oy2g==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.61.1.tgz} cpu: [arm64] os: [win32] '@rollup/rollup-win32-ia32-msvc@4.61.1': - resolution: {integrity: sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==} + resolution: {integrity: sha512-0mYtjHS9ucAbcATycCNK9IGBk/cCe/ma7EmSLGZdsxnOA8cjRIyU04wDpVAD9NiOfLUR9KTxdiO53uOkherqjQ==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.61.1.tgz} cpu: [ia32] os: [win32] '@rollup/rollup-win32-x64-gnu@4.61.1': - resolution: {integrity: sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==} + resolution: {integrity: sha512-gK1iCEPfpoSG9wfBihXxvBMi8ZfcWffYkEsC/Eih+iFENTaewvNcrEQ69lIOWYO5pePHKLHHO7nq5AILGO/HQQ==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.61.1.tgz} cpu: [x64] os: [win32] '@rollup/rollup-win32-x64-msvc@4.61.1': - resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==} + resolution: {integrity: sha512-X+zaP2x+j4RXGfbp/seSoRHWnPxzApilDszisZxbYH5C/jTxFhCtDNdPGZb9lJyYPs24wGxruPF7Y+sIXt9Gzw==, tarball: https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.61.1.tgz} cpu: [x64] os: [win32] @@ -828,7 +847,7 @@ packages: resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} '@tybys/wasm-util@0.10.2': - resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} + resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==, tarball: https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz} '@types/argparse@1.0.38': resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} @@ -926,55 +945,55 @@ packages: engines: {node: '>=10.0.0'} '@xn-sakina/rml-darwin-arm64@2.8.0': - resolution: {integrity: sha512-B8XpWn/t3vALCePi8DnbHQWVQnmKybwPIKbfzL1L75w2V/ELXn0OguFayujf2eZdmCIBPC+Drqxztn6fDV08Ww==} + resolution: {integrity: sha512-B8XpWn/t3vALCePi8DnbHQWVQnmKybwPIKbfzL1L75w2V/ELXn0OguFayujf2eZdmCIBPC+Drqxztn6fDV08Ww==, tarball: https://registry.npmjs.org/@xn-sakina/rml-darwin-arm64/-/rml-darwin-arm64-2.8.0.tgz} engines: {node: '>=14'} cpu: [arm64] os: [darwin] '@xn-sakina/rml-darwin-x64@2.8.0': - resolution: {integrity: sha512-02UG6vhkzoTgQwkPJH8cnz7Pmqs0BChv4dq13pYqUtSSJr9BgPkSA5U8lXXmD76HpUOdN1ZH4K1jr2NOhwbPCw==} + resolution: {integrity: sha512-02UG6vhkzoTgQwkPJH8cnz7Pmqs0BChv4dq13pYqUtSSJr9BgPkSA5U8lXXmD76HpUOdN1ZH4K1jr2NOhwbPCw==, tarball: https://registry.npmjs.org/@xn-sakina/rml-darwin-x64/-/rml-darwin-x64-2.8.0.tgz} engines: {node: '>=14'} cpu: [x64] os: [darwin] '@xn-sakina/rml-linux-arm-gnueabihf@2.8.0': - resolution: {integrity: sha512-T2S2aGm7mcyIUxkMAni+ClgWp4G8zDe6eApQRNK77G6D/6m25YDrMn+YmVs3TJOA9Qi4RoNaztihni6+B+IOHQ==} + resolution: {integrity: sha512-T2S2aGm7mcyIUxkMAni+ClgWp4G8zDe6eApQRNK77G6D/6m25YDrMn+YmVs3TJOA9Qi4RoNaztihni6+B+IOHQ==, tarball: https://registry.npmjs.org/@xn-sakina/rml-linux-arm-gnueabihf/-/rml-linux-arm-gnueabihf-2.8.0.tgz} engines: {node: '>=14'} cpu: [arm] os: [linux] '@xn-sakina/rml-linux-arm64-gnu@2.8.0': - resolution: {integrity: sha512-Vr9lz9vCXXDHaOzAChoxgPeCFn2vTLsZjmxJFBXXcUyN2ixoZacreHE2aro/XdOuX5yNClsIWsTrkZoBY3kAEw==} + resolution: {integrity: sha512-Vr9lz9vCXXDHaOzAChoxgPeCFn2vTLsZjmxJFBXXcUyN2ixoZacreHE2aro/XdOuX5yNClsIWsTrkZoBY3kAEw==, tarball: https://registry.npmjs.org/@xn-sakina/rml-linux-arm64-gnu/-/rml-linux-arm64-gnu-2.8.0.tgz} engines: {node: '>=14'} cpu: [arm64] os: [linux] '@xn-sakina/rml-linux-arm64-musl@2.8.0': - resolution: {integrity: sha512-GFDdj1+bzMwoyPhybM83f4aDrLLROYHVcC1+xHNa/zaRp/CI4j66fQwvw1YrTgs+Vk6ZLCm+HtKlA/BO8B9NjQ==} + resolution: {integrity: sha512-GFDdj1+bzMwoyPhybM83f4aDrLLROYHVcC1+xHNa/zaRp/CI4j66fQwvw1YrTgs+Vk6ZLCm+HtKlA/BO8B9NjQ==, tarball: https://registry.npmjs.org/@xn-sakina/rml-linux-arm64-musl/-/rml-linux-arm64-musl-2.8.0.tgz} engines: {node: '>=14'} cpu: [arm64] os: [linux] '@xn-sakina/rml-linux-x64-gnu@2.8.0': - resolution: {integrity: sha512-2Lha6vIfI5pdIE2Odqovs9KHuJT8S7ql313pYXhUjsxi+da965kLfK1xk0dWs9eo6aEFp4+9Ny+XIIjG2agDZw==} + resolution: {integrity: sha512-2Lha6vIfI5pdIE2Odqovs9KHuJT8S7ql313pYXhUjsxi+da965kLfK1xk0dWs9eo6aEFp4+9Ny+XIIjG2agDZw==, tarball: https://registry.npmjs.org/@xn-sakina/rml-linux-x64-gnu/-/rml-linux-x64-gnu-2.8.0.tgz} engines: {node: '>=14'} cpu: [x64] os: [linux] '@xn-sakina/rml-linux-x64-musl@2.8.0': - resolution: {integrity: sha512-F2/JCzyqOEfFe62WbSf+4/rcK7pyjYpABGmopUJon5GSUVGXRVQOfkkqg6ceufw1ipQUCWsU3Cj0vmd0yvzkmg==} + resolution: {integrity: sha512-F2/JCzyqOEfFe62WbSf+4/rcK7pyjYpABGmopUJon5GSUVGXRVQOfkkqg6ceufw1ipQUCWsU3Cj0vmd0yvzkmg==, tarball: https://registry.npmjs.org/@xn-sakina/rml-linux-x64-musl/-/rml-linux-x64-musl-2.8.0.tgz} engines: {node: '>=14'} cpu: [x64] os: [linux] '@xn-sakina/rml-win32-arm64-msvc@2.8.0': - resolution: {integrity: sha512-ikmzMj9OwRMMtFmE7D4aSi1K1G8C350orJz9ngLTj39p7l0NQeXQfQ0SOah5RQSNWV00XJriJBzVHkWS701xeA==} + resolution: {integrity: sha512-ikmzMj9OwRMMtFmE7D4aSi1K1G8C350orJz9ngLTj39p7l0NQeXQfQ0SOah5RQSNWV00XJriJBzVHkWS701xeA==, tarball: https://registry.npmjs.org/@xn-sakina/rml-win32-arm64-msvc/-/rml-win32-arm64-msvc-2.8.0.tgz} engines: {node: '>=14'} cpu: [arm64] os: [win32] '@xn-sakina/rml-win32-x64-msvc@2.8.0': - resolution: {integrity: sha512-xeyoBIfccb7prfiUVLv0K+1HlLT4vnDfXHSkLpsp4wI0LtYR7sYbiGaMVgNdgR2nDHC+W+65inUHvhY5m7pbnw==} + resolution: {integrity: sha512-xeyoBIfccb7prfiUVLv0K+1HlLT4vnDfXHSkLpsp4wI0LtYR7sYbiGaMVgNdgR2nDHC+W+65inUHvhY5m7pbnw==, tarball: https://registry.npmjs.org/@xn-sakina/rml-win32-x64-msvc/-/rml-win32-x64-msvc-2.8.0.tgz} engines: {node: '>=14'} cpu: [x64] os: [win32] @@ -1124,7 +1143,7 @@ packages: resolution: {integrity: sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==} esbuild@0.28.1: - resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==} + resolution: {integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==, tarball: https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz} engines: {node: '>=18'} hasBin: true @@ -1176,7 +1195,7 @@ packages: engines: {node: '>=14.14'} fsevents@2.3.3: - resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, tarball: https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz} engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} os: [darwin] @@ -1269,71 +1288,71 @@ packages: resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} lightningcss-android-arm64@1.32.0: - resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==} + resolution: {integrity: sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==, tarball: https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [android] lightningcss-darwin-arm64@1.32.0: - resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==} + resolution: {integrity: sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==, tarball: https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [darwin] lightningcss-darwin-x64@1.32.0: - resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==} + resolution: {integrity: sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==, tarball: https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [x64] os: [darwin] lightningcss-freebsd-x64@1.32.0: - resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==} + resolution: {integrity: sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==, tarball: https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [x64] os: [freebsd] lightningcss-linux-arm-gnueabihf@1.32.0: - resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==} + resolution: {integrity: sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==, tarball: https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm] os: [linux] lightningcss-linux-arm64-gnu@1.32.0: - resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==} + resolution: {integrity: sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==, tarball: https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: - resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} + resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==, tarball: https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] libc: [musl] lightningcss-linux-x64-gnu@1.32.0: - resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} + resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==, tarball: https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [glibc] lightningcss-linux-x64-musl@1.32.0: - resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} + resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==, tarball: https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: - resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} + resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==, tarball: https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [win32] lightningcss-win32-x64-msvc@1.32.0: - resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==} + resolution: {integrity: sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==, tarball: https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz} engines: {node: '>= 12.0.0'} cpu: [x64] os: [win32] @@ -1491,7 +1510,7 @@ packages: hasBin: true rollup@4.61.1: - resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==} + resolution: {integrity: sha512-I4KW6iuRpuu2uHBLraZ1wNZe0DP7lnRha+VJ9tNaYVaVgKhW0aI3h4RYnoRPeql0flHm/Co55b7snEDcOfOJrA==, tarball: https://registry.npmjs.org/rollup/-/rollup-4.61.1.tgz} engines: {node: '>=18.0.0', npm: '>=8.0.0'} hasBin: true @@ -1584,7 +1603,7 @@ packages: engines: {node: '>=8.0'} tslib@2.8.1: - resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, tarball: https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz} typescript@5.4.5: resolution: {integrity: sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==} @@ -1592,7 +1611,7 @@ packages: hasBin: true typescript@5.9.3: - resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==} + resolution: {integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==, tarball: https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz} engines: {node: '>=14.17'} hasBin: true diff --git a/platforms/web/pnpm-workspace.yaml b/platforms/web/pnpm-workspace.yaml index a9fd8205a..81b60d90a 100644 --- a/platforms/web/pnpm-workspace.yaml +++ b/platforms/web/pnpm-workspace.yaml @@ -1,2 +1,3 @@ packages: - '../../protocol/languages/typescript' + - '../../telemetry/languages/typescript' diff --git a/telemetry/README.md b/telemetry/README.md new file mode 100644 index 000000000..11d7f09b0 --- /dev/null +++ b/telemetry/README.md @@ -0,0 +1,56 @@ +# Checkout Kit telemetry + +Checkout Kit telemetry is a metrics-only client used by the Swift, Android, +React Native, and Web SDKs — React Native reports through the embedded native +SDKs. It exports anonymous operational metrics using OTLP/HTTP JSON. + +The implementations intentionally do not install a global OpenTelemetry +provider. They aggregate a small, closed set of Checkout Kit metrics and send +them directly to the configured collector with bounded in-memory buffering and +bounded exponential backoff after export failures. + +## Implementations + +- `languages/swift` — package-visible `CheckoutKitTelemetry` Swift target +- `platforms/android/lib` — internal Kotlin implementation +- `languages/typescript` — private `@shopify/checkout-kit-telemetry` workspace package + +All implementations are Checkout Kit internals. They are not independently +published or exposed as public SDK APIs. The Swift and TypeScript targets keep +their implementation boundaries private; Android telemetry is part of the +Checkout Kit library. Its internal OTLP exporter owns batching, backoff, payload +encoding, and transport so those concerns remain isolated from integrations. + +## Data rules + +Telemetry must not contain checkout, cart, shop, customer, application, or +device identifiers. Raw URLs, protocol payloads, HTTP bodies, exception +messages, and stack traces are prohibited. Metric names and attributes are +defined in [`contract/metrics.md`](contract/metrics.md). + +Telemetry failures are always isolated from checkout. Buffers are held only in +memory and are discarded when the process exits. + +Calling `shutdown` performs a final flush by default. Integrations implementing +a runtime opt-out can request that pending measurements be discarded instead; +this also cancels an active request where the platform transport supports it. +A request already handed off to the operating system or remote endpoint may +not be retractable. + +## Lifecycle integration + +Create and start one enabled telemetry client for the lifetime of the SDK-owned +integration. `flush` is a best-effort operation for periodic work and terminal +page lifecycle events; it does not dispose the client. Call `shutdown` only +when the client is permanently disposed or telemetry is disabled. Use discard +shutdown for an opt-out so queued measurements are dropped and an active request +is cancelled where supported. + +Android telemetry is process-scoped and must not be shut down from activity +`onPause`, `onStop`, or `onDestroy`; `Application.onTerminate` is not a +production lifecycle callback. Web integrations flush on `pagehide` and shut +down only when their SDK-owned client is disposed. Swift integrations shut down +when telemetry is disabled. + +Telemetry is an SDK implementation detail. It does not add UCP/ECP methods or +send telemetry through the embedded checkout protocol. diff --git a/telemetry/contract/metrics.md b/telemetry/contract/metrics.md new file mode 100644 index 000000000..4cc54999c --- /dev/null +++ b/telemetry/contract/metrics.md @@ -0,0 +1,84 @@ +# Metrics contract + +All implementations use the following OTLP resource attributes: + +- `service.name`: `checkout-kit` +- `service.version`: the Checkout Kit release version +- `telemetry.sdk.language`: `java`, `swift`, or `webjs` +- `telemetry.sdk.name`: `checkout-kit-telemetry` +- `telemetry.sdk.version`: the Checkout Kit release version that includes the + telemetry implementation + +Every metric includes these closed identity attributes: + +- `product`: `checkout_kit`, `accelerated_checkouts`, or `customer_auth` +- `platform`: `android`, `swift`, `web`, `react-native-android`, or + `react-native-swift` + +React Native values include the underlying native runtime so dashboards can query +one bounded dimension without joining separate platform and integration +attributes. Metric attributes are closed, low-cardinality values; callers cannot +attach arbitrary attributes. + +## Metrics + +### `checkout_kit_error` + +Monotonic delta counter for failures observed by the SDK. + +Attributes: + +- `category`: `http`, `navigation`, `protocol`, `render_process`, or `unknown` +- `stage`: `initialization`, `load`, `message`, or `presentation` +- `code`: a bounded platform-independent code, falling back to `unknown` +- `retryable`: `true` or `false` +- `is_retry`: `true` when the error occurred during a retry attempt, otherwise + `false` + +### `checkout_kit_protocol_decode_error` + +Monotonic delta counter for ECP messages that cannot be decoded. + +Attributes: + +- `method`: a supported ECP method or `unknown` +- `failure_type`: `envelope`, `params`, `serialization`, or `unknown` + +The raw message and decoder error are never recorded. + +### `checkout_kit_navigation_retry` + +Monotonic delta counter for retry decisions. + +Attributes: + +- `reason`: `timeout`, `connection_lost`, `cannot_connect`, `dns`, or `unknown` +- `result`: `started`, `failed`, or `not_attempted` + +`started` means a retry was launched, `failed` means that launched retry later +failed, and `not_attempted` means an eligible retry could not be launched. + +### `checkout_kit_navigation_duration_ms` + +Delta histogram measuring the initial main-frame checkout navigation, from the +navigation start until success or terminal failure. Subresource requests and +subsequent WebView navigations are not measured. + +Attributes: + +- `result`: `success` or `failure` +- `preloaded`: `true` or `false` + +## Error mappings + +A lost background preload keep-alive is recorded as +`category=navigation`, `stage=load`, `code=connection_lost`, +`retryable=false`, and `is_retry=false`. + +## Prohibited data + +- Checkout, cart, order, shop, customer, or payment identifiers +- App bundle/package identifiers or stable device identifiers +- URLs, origins, query parameters, HTTP bodies, or headers +- Raw UCP/ECP messages or decoded payload content +- Exception text, error descriptions, or stack traces diff --git a/telemetry/languages/swift/Sources/CheckoutKitTelemetry/CheckoutKitTelemetry.swift b/telemetry/languages/swift/Sources/CheckoutKitTelemetry/CheckoutKitTelemetry.swift new file mode 100644 index 000000000..6e259017a --- /dev/null +++ b/telemetry/languages/swift/Sources/CheckoutKitTelemetry/CheckoutKitTelemetry.swift @@ -0,0 +1,560 @@ +#if !COCOAPODS + import EmbeddedCheckoutProtocol +#endif +import Foundation + +package enum TelemetryErrorCategory: String, Sendable { + case http + case navigation + case `protocol` + case renderProcess = "render_process" + case unknown +} + +package enum TelemetryErrorStage: String, Sendable { + case initialization + case load + case message + case presentation +} + +package enum TelemetryErrorCode: String, Sendable { + case client = "4xx" + case server = "5xx" + case cancelled + case connectionLost = "connection_lost" + case cannotConnect = "cannot_connect" + case dns + case timeout + case unknown +} + +package struct TelemetryProtocolMethod: Sendable { + package let rawValue: String + + package init(method: String) { + rawValue = EmbeddedCheckoutProtocol.Event.all.contains(method) ? method : "unknown" + } +} + +package enum TelemetryDecodeFailureType: String, Sendable { + case envelope + case params + case serialization + case unknown +} + +package enum TelemetryNavigationRetryReason: String, Sendable { + case timeout + case connectionLost = "connection_lost" + case cannotConnect = "cannot_connect" + case dns + case unknown +} + +package enum TelemetryNavigationRetryResult: String, Sendable { + case started + case failed + case notAttempted = "not_attempted" +} + +package enum TelemetryNavigationDurationResult: String, Sendable { + case success + case failure +} + +package enum TelemetryProduct: String, Sendable { + case checkoutKit = "checkout_kit" + case acceleratedCheckouts = "accelerated_checkouts" + case customerAuth = "customer_auth" +} + +package enum TelemetryPlatform: String, Sendable { + case swift + case reactNativeSwift = "react-native-swift" +} + +package struct TelemetryErrorMetric: Sendable { + package let category: TelemetryErrorCategory + package let stage: TelemetryErrorStage + package let code: TelemetryErrorCode + package let retryable: Bool + package let isRetry: Bool + + package init( + category: TelemetryErrorCategory, + stage: TelemetryErrorStage, + code: TelemetryErrorCode, + retryable: Bool, + isRetry: Bool = false + ) { + self.category = category + self.stage = stage + self.code = code + self.retryable = retryable + self.isRetry = isRetry + } +} + +package struct TelemetryProtocolDecodeErrorMetric: Sendable { + package let method: TelemetryProtocolMethod + package let failureType: TelemetryDecodeFailureType + + package init(method: TelemetryProtocolMethod, failureType: TelemetryDecodeFailureType) { + self.method = method + self.failureType = failureType + } +} + +package struct TelemetryNavigationRetryMetric: Sendable { + package let reason: TelemetryNavigationRetryReason + package let result: TelemetryNavigationRetryResult + + package init(reason: TelemetryNavigationRetryReason, result: TelemetryNavigationRetryResult) { + self.reason = reason + self.result = result + } +} + +package struct TelemetryNavigationDurationMetric: Sendable { + package let milliseconds: Double + package let result: TelemetryNavigationDurationResult + package let preloaded: Bool + + package init(milliseconds: Double, result: TelemetryNavigationDurationResult, preloaded: Bool) { + self.milliseconds = milliseconds + self.result = result + self.preloaded = preloaded + } +} + +package final class CheckoutKitTelemetry: @unchecked Sendable { + package static let productionEndpoint = URL(string: "https://otlp-http-production.shopifysvc.com/v1/metrics")! + + package struct Configuration: Sendable { + package var sdkVersion: String + package var product: TelemetryProduct + package var platform: TelemetryPlatform + package var endpoint: URL + package var exportInterval: TimeInterval + package var maxPendingMeasurements: Int + + package init( + sdkVersion: String, + product: TelemetryProduct = .checkoutKit, + platform: TelemetryPlatform = .swift, + endpoint: URL = CheckoutKitTelemetry.productionEndpoint, + exportInterval: TimeInterval = 60, + maxPendingMeasurements: Int = 128 + ) { + self.sdkVersion = sdkVersion + self.product = product + self.platform = platform + self.endpoint = endpoint + self.exportInterval = exportInterval + self.maxPendingMeasurements = max(1, maxPendingMeasurements) + } + } + + typealias Transport = @Sendable (URLRequest) async throws -> HTTPURLResponse + typealias Clock = @Sendable () -> UInt64 + + private let configuration: Configuration + private let transport: Transport + private let clock: Clock + private let lock = NSLock() + private let timerQueue = DispatchQueue(label: "com.shopify.checkout-kit.telemetry") + private var measurements: [Measurement] = [] + private var timer: DispatchSourceTimer? + private var consecutiveExportFailures = 0 + private var nextExportAllowedAt: Date? + private var activeExportTask: Task? + private var stopping = false + private var stopped = false + + private enum FlushAction { + case complete(Bool) + case wait(Task) + } + + private enum ShutdownAction { + case complete + case discard(Task?) + case drain(Task?) + } + + package convenience init(configuration: Configuration) { + self.init( + configuration: configuration, + clock: { UInt64(Date().timeIntervalSince1970 * 1_000_000_000) }, + transport: { request in + let (_, response) = try await URLSession.shared.data(for: request) + guard let response = response as? HTTPURLResponse else { + throw TelemetryTransportError.invalidResponse + } + return response + } + ) + } + + init(configuration: Configuration, clock: @escaping Clock, transport: @escaping Transport) { + self.configuration = configuration + self.clock = clock + self.transport = transport + } + + deinit { + timer?.cancel() + } + + package func start() { + guard configuration.exportInterval > 0 else { return } + + let timer = lock.withLock { () -> DispatchSourceTimer? in + guard !stopped, self.timer == nil else { return nil } + let timer = DispatchSource.makeTimerSource(queue: timerQueue) + self.timer = timer + return timer + } + guard let timer else { return } + timer.schedule(deadline: .now() + configuration.exportInterval, repeating: configuration.exportInterval) + timer.setEventHandler { [weak self] in + guard let self else { return } + Task.detached { [self] in _ = await flush() } + } + timer.resume() + } + + package func recordError(_ metric: TelemetryErrorMetric) { + recordCounter( + name: "checkout_kit_error", + attributes: attributes([ + "category": .string(metric.category.rawValue), + "stage": .string(metric.stage.rawValue), + "code": .string(metric.code.rawValue), + "retryable": .bool(metric.retryable), + "is_retry": .bool(metric.isRetry) + ]) + ) + } + + package func recordProtocolDecodeError(_ metric: TelemetryProtocolDecodeErrorMetric) { + recordCounter( + name: "checkout_kit_protocol_decode_error", + attributes: attributes([ + "method": .string(metric.method.rawValue), + "failure_type": .string(metric.failureType.rawValue) + ]) + ) + } + + package func recordNavigationRetry(_ metric: TelemetryNavigationRetryMetric) { + recordCounter( + name: "checkout_kit_navigation_retry", + attributes: attributes([ + "reason": .string(metric.reason.rawValue), + "result": .string(metric.result.rawValue) + ]) + ) + } + + package func recordNavigationDuration(_ metric: TelemetryNavigationDurationMetric) { + guard metric.milliseconds.isFinite, metric.milliseconds >= 0 else { return } + record( + Measurement( + type: .histogram, + name: "checkout_kit_navigation_duration_ms", + value: metric.milliseconds, + unit: "ms", + attributes: attributes([ + "result": .string(metric.result.rawValue), + "preloaded": .bool(metric.preloaded) + ]), + timeUnixNano: clock() + ) + ) + } + + package func flush() async -> Bool { + await flush(ignoreBackoff: false, allowStopping: false) + } + + package func shutdown(discardPending: Bool = false) async -> Bool { + let action = lock.withLock { () -> ShutdownAction in + guard !stopped else { return .complete } + timer?.cancel() + timer = nil + if discardPending { + stopped = true + stopping = true + measurements.removeAll(keepingCapacity: false) + return .discard(activeExportTask) + } + stopping = true + return .drain(activeExportTask) + } + + switch action { + case .complete: + return true + case let .discard(activeTask): + activeTask?.cancel() + _ = await activeTask?.value + return true + case let .drain(activeTask): + let activeExportSucceeded = await activeTask?.value ?? true + let finalFlushSucceeded = await flush(ignoreBackoff: true, allowStopping: true) + lock.withLock { stopped = true } + return activeExportSucceeded && finalFlushSucceeded + } + } + + private func flush(ignoreBackoff: Bool, allowStopping: Bool) async -> Bool { + let action = lock.withLock { () -> FlushAction in + guard !stopped, allowStopping || !stopping else { return .complete(false) } + if !ignoreBackoff, let nextExportAllowedAt, nextExportAllowedAt > Date() { + return .complete(false) + } + if let activeExportTask { return .wait(activeExportTask) } + guard !measurements.isEmpty else { return .complete(true) } + + let pending = measurements + measurements.removeAll(keepingCapacity: true) + let task = Task { [weak self] in + await self?.export(pending) ?? false + } + activeExportTask = task + return .wait(task) + } + + switch action { + case let .complete(succeeded): return succeeded + case let .wait(task): return await task.value + } + } + + private func recordCounter(name: String, attributes: [String: AttributeValue]) { + record( + Measurement( + type: .counter, + name: name, + value: 1, + unit: nil, + attributes: attributes, + timeUnixNano: clock() + ) + ) + } + + private func attributes(_ values: [String: AttributeValue]) -> [String: AttributeValue] { + var attributes = values + attributes["product"] = .string(configuration.product.rawValue) + attributes["platform"] = .string(configuration.platform.rawValue) + return attributes + } + + private func record(_ measurement: Measurement) { + lock.withLock { + guard !stopped, !stopping else { return } + guard measurements.count < configuration.maxPendingMeasurements else { return } + measurements.append(measurement) + } + } + + private func export(_ pending: [Measurement]) async -> Bool { + let succeeded: Bool + do { + let body = try OtlpPayload.make( + sdkVersion: configuration.sdkVersion, + measurements: pending + ) + var request = URLRequest(url: configuration.endpoint) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.httpBody = body + let response = try await transport(request) + succeeded = (200 ..< 300).contains(response.statusCode) + } catch { + succeeded = false + } + return completeExport(pending: pending, succeeded: succeeded) + } + + private func completeExport(pending: [Measurement], succeeded: Bool) -> Bool { + lock.withLock { + activeExportTask = nil + guard !stopped else { return false } + if succeeded { + consecutiveExportFailures = 0 + nextExportAllowedAt = nil + } else { + measurements = Array((pending + measurements).prefix(configuration.maxPendingMeasurements)) + consecutiveExportFailures += 1 + let exponent = min(consecutiveExportFailures - 1, maximumBackoffExponent) + let backoff = min( + configuration.exportInterval * pow(2, Double(exponent)), + maximumExportBackoff + ) + nextExportAllowedAt = Date().addingTimeInterval(backoff) + } + return succeeded + } + } +} + +private let maximumExportBackoff: TimeInterval = 15 * 60 +private let maximumBackoffExponent = 4 +private let instrumentationName = "checkout-kit-telemetry" +private let telemetrySDKLanguage = "swift" + +private enum TelemetryTransportError: Error { + case invalidResponse +} + +private enum MeasurementType: String, Hashable, Sendable { + case counter + case histogram +} + +private enum AttributeValue: Hashable, Sendable { + case string(String) + case bool(Bool) + + var json: [String: Any] { + switch self { + case let .string(value): ["stringValue": value] + case let .bool(value): ["boolValue": value] + } + } + + var sortValue: String { + switch self { + case let .string(value): "s:\(value)" + case let .bool(value): "b:\(value)" + } + } +} + +private struct Measurement: Sendable { + let type: MeasurementType + let name: String + let value: Double + let unit: String? + let attributes: [String: AttributeValue] + let timeUnixNano: UInt64 +} + +private struct MetricKey: Hashable { + struct Attribute: Hashable { + let key: String + let value: AttributeValue + } + + let type: MeasurementType + let name: String + let attributes: [Attribute] + + var attributesSortValue: String { + attributes + .map { "\($0.key)=\($0.value.sortValue)" } + .joined(separator: "&") + } + + init(_ measurement: Measurement) { + type = measurement.type + name = measurement.name + attributes = measurement.attributes + .map { Attribute(key: $0.key, value: $0.value) } + .sorted { $0.key < $1.key } + } +} + +private enum OtlpPayload { + private static let histogramBounds: [Double] = [100, 250, 500, 1000, 2500, 5000, 10000, 30000] + + static func make(sdkVersion: String, measurements: [Measurement]) throws -> Data { + let grouped = Dictionary(grouping: measurements, by: MetricKey.init) + let metrics = grouped + .sorted { + if $0.key.name != $1.key.name { return $0.key.name < $1.key.name } + return $0.key.attributesSortValue < $1.key.attributesSortValue + } + .map(buildMetric) + let payload: [String: Any] = [ + "resourceMetrics": [[ + "resource": [ + "attributes": encodeAttributes([ + "service.name": .string("checkout-kit"), + "service.version": .string(sdkVersion), + "telemetry.sdk.language": .string(telemetrySDKLanguage), + "telemetry.sdk.name": .string(instrumentationName), + "telemetry.sdk.version": .string(sdkVersion) + ]) + ], + "scopeMetrics": [[ + "scope": ["name": instrumentationName, "version": sdkVersion], + "metrics": metrics + ]] + ]] + ] + return try JSONSerialization.data(withJSONObject: payload, options: [.sortedKeys]) + } + + private static func buildMetric(group: (key: MetricKey, value: [Measurement])) -> [String: Any] { + let measurements = group.value.sorted { $0.timeUnixNano < $1.timeUnixNano } + let first = measurements[0] + let attributes = encodeAttributes(first.attributes) + let start = String(first.timeUnixNano) + let end = String(measurements[measurements.count - 1].timeUnixNano) + + switch group.key.type { + case .counter: + return [ + "name": first.name, + "sum": [ + "aggregationTemporality": 1, + "isMonotonic": true, + "dataPoints": [[ + "attributes": attributes, + "asInt": String(measurements.count), + "startTimeUnixNano": start, + "timeUnixNano": end + ]] + ] + ] + case .histogram: + let values = measurements.map(\.value) + var bucketCounts = Array(repeating: 0, count: histogramBounds.count + 1) + for value in values { + let index = histogramBounds.firstIndex { value <= $0 } ?? histogramBounds.count + bucketCounts[index] += 1 + } + let minimum = values.min() ?? 0 + let maximum = values.max() ?? 0 + return [ + "name": first.name, + "unit": first.unit ?? "", + "histogram": [ + "aggregationTemporality": 1, + "dataPoints": [[ + "attributes": attributes, + "bucketCounts": bucketCounts.map(String.init), + "count": String(values.count), + "explicitBounds": histogramBounds, + "min": minimum, + "max": maximum, + "sum": values.reduce(0, +), + "startTimeUnixNano": start, + "timeUnixNano": end + ]] + ] + ] + } + } + + private static func encodeAttributes(_ attributes: [String: AttributeValue]) -> [[String: Any]] { + attributes + .sorted { $0.key < $1.key } + .map { ["key": $0.key, "value": $0.value.json] } + } +} diff --git a/telemetry/languages/swift/Tests/CheckoutKitTelemetryTests/CheckoutKitTelemetryTests.swift b/telemetry/languages/swift/Tests/CheckoutKitTelemetryTests/CheckoutKitTelemetryTests.swift new file mode 100644 index 000000000..0b1032149 --- /dev/null +++ b/telemetry/languages/swift/Tests/CheckoutKitTelemetryTests/CheckoutKitTelemetryTests.swift @@ -0,0 +1,308 @@ +@testable import CheckoutKitTelemetry +import Foundation +import Testing + +struct CheckoutKitTelemetryTests { + @Test func aggregatesCountersAndUsesClosedAttributes() async throws { + let recorder = RequestRecorder() + let times = LockedTimes([1_000_000, 2_000_000]) + let client = CheckoutKitTelemetry( + configuration: .init( + sdkVersion: "1.2.3", + product: .acceleratedCheckouts, + platform: .reactNativeSwift + ), + clock: { times.next() }, + transport: { request in + recorder.record(request) + return HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + } + ) + + let metric = TelemetryErrorMetric( + category: .http, + stage: .load, + code: .server, + retryable: true, + isRetry: true + ) + client.recordError(metric) + client.recordError(metric) + + #expect(await client.flush()) + let request = try #require(recorder.request) + let body = try #require(request.httpBody) + let json = try #require(JSONSerialization.jsonObject(with: body) as? [String: Any]) + let resourceMetrics = try #require(json["resourceMetrics"] as? [[String: Any]]) + let resource = try #require(resourceMetrics[0]["resource"] as? [String: Any]) + let resourceAttributes = try #require(resource["attributes"] as? [[String: Any]]) + #expect(stringAttributes(resourceAttributes) == [ + "service.name": "checkout-kit", + "service.version": "1.2.3", + "telemetry.sdk.language": "swift", + "telemetry.sdk.name": "checkout-kit-telemetry", + "telemetry.sdk.version": "1.2.3" + ]) + let scopeMetrics = try #require(resourceMetrics[0]["scopeMetrics"] as? [[String: Any]]) + let metrics = try #require(scopeMetrics[0]["metrics"] as? [[String: Any]]) + let sum = try #require(metrics[0]["sum"] as? [String: Any]) + let points = try #require(sum["dataPoints"] as? [[String: Any]]) + let pointAttributes = try #require(points[0]["attributes"] as? [[String: Any]]) + #expect(stringAttributes(pointAttributes)["product"] == "accelerated_checkouts") + #expect(stringAttributes(pointAttributes)["platform"] == "react-native-swift") + #expect(stringAttributes(pointAttributes)["integration"] == nil) + #expect(boolAttributes(pointAttributes)["is_retry"] == true) + #expect(points[0]["asInt"] as? String == "2") + #expect(String(data: body, encoding: .utf8)?.contains("checkoutUrl") == false) + } + + @Test func isolatesTransportFailure() async { + let client = CheckoutKitTelemetry( + configuration: .init(sdkVersion: "1.2.3"), + clock: { 1 }, + transport: { _ in + throw TestError.unavailable + } + ) + client.recordProtocolDecodeError(.init(method: .init(method: "ec.start"), failureType: .params)) + #expect(await client.flush() == false) + } + + @Test func boundsPendingMeasurementsAndClampsCapacity() async throws { + let recorder = RequestRecorder() + let client = CheckoutKitTelemetry( + configuration: .init(sdkVersion: "1.2.3", maxPendingMeasurements: 0), + clock: { 1 }, + transport: successfulTransport(recorder: recorder) + ) + client.recordNavigationRetry(.init(reason: .timeout, result: .started)) + client.recordNavigationRetry(.init(reason: .dns, result: .failed)) + + _ = await client.flush() + + let body = try #require(recorder.request?.httpBody) + #expect(String(data: body, encoding: .utf8)?.contains("\"asInt\":\"1\"") == true) + } + + @Test func backsOffAfterExportFailure() async { + let attempts = LockedInt(0) + let client = CheckoutKitTelemetry( + configuration: .init(sdkVersion: "1.2.3", exportInterval: 60), + clock: { 1 }, + transport: { _ in + attempts.increment() + throw TestError.unavailable + } + ) + client.recordError(.init(category: .http, stage: .load, code: .server, retryable: true)) + _ = await client.flush() + client.recordError(.init(category: .http, stage: .load, code: .server, retryable: true)) + + #expect(await client.flush() == false) + #expect(attempts.value == 1) + } + + @Test func recordsFiniteNonNegativeDurationsOnly() async throws { + let recorder = RequestRecorder() + let client = CheckoutKitTelemetry( + configuration: .init(sdkVersion: "1.2.3"), + clock: { 1 }, + transport: successfulTransport(recorder: recorder) + ) + client.recordNavigationDuration(.init(milliseconds: .nan, result: .failure, preloaded: false)) + client.recordNavigationDuration(.init(milliseconds: 450, result: .success, preloaded: false)) + + _ = await client.flush() + + let body = try #require(recorder.request?.httpBody) + let string = try #require(String(data: body, encoding: .utf8)) + #expect(string.contains("\"count\":\"1\"")) + #expect(string.contains("\"sum\":450")) + } + + @Test func shutdownBypassesExportBackoffAndRetriesFailedMeasurements() async throws { + let attempts = LockedInt(0) + let recorder = RequestRecorder() + let client = CheckoutKitTelemetry( + configuration: .init(sdkVersion: "1.2.3"), + clock: { 1 }, + transport: { request in + recorder.record(request) + attempts.increment() + let status = attempts.value == 1 ? 500 : 200 + return HTTPURLResponse(url: request.url!, statusCode: status, httpVersion: nil, headerFields: nil)! + } + ) + let metric = TelemetryErrorMetric(category: .http, stage: .load, code: .server, retryable: true) + client.recordError(metric) + _ = await client.flush() + client.recordError(metric) + + #expect(await client.shutdown()) + #expect(attempts.value == 2) + let body = try #require(recorder.request?.httpBody) + #expect(String(data: body, encoding: .utf8)?.contains("\"asInt\":\"2\"") == true) + } + + @Test func acceptsMethodsAddedToTheGeneratedProtocolCatalog() async throws { + let recorder = RequestRecorder() + let client = CheckoutKitTelemetry( + configuration: .init(sdkVersion: "1.2.3"), + clock: { 1 }, + transport: successfulTransport(recorder: recorder) + ) + + client.recordProtocolDecodeError( + .init(method: .init(method: "ec.buyer.change"), failureType: .params) + ) + + _ = await client.flush() + + let body = try #require(recorder.request?.httpBody) + #expect(String(data: body, encoding: .utf8)?.contains("ec.buyer.change") == true) + } + + @Test func ordersGroupsWithMatchingNamesDeterministically() async throws { + let recorder = RequestRecorder() + let client = CheckoutKitTelemetry( + configuration: .init(sdkVersion: "1.2.3"), + clock: { 1 }, + transport: successfulTransport(recorder: recorder) + ) + client.recordError(.init(category: .http, stage: .load, code: .server, retryable: true)) + client.recordError(.init(category: .http, stage: .load, code: .client, retryable: false)) + + _ = await client.flush() + + let body = try #require(recorder.request?.httpBody) + let string = try #require(String(data: body, encoding: .utf8)) + let clientRange = try #require(string.range(of: "4xx")) + let serverRange = try #require(string.range(of: "5xx")) + #expect(clientRange.lowerBound < serverRange.lowerBound) + } + + @Test func discardShutdownCancelsTransportAndMakesLifecycleSafe() async { + let client = CheckoutKitTelemetry( + configuration: .init(sdkVersion: "1.2.3"), + clock: { 1 }, + transport: { _ in + try await Task.sleep(nanoseconds: 60_000_000_000) + throw TestError.unavailable + } + ) + client.recordError(.init(category: .http, stage: .load, code: .server, retryable: true)) + let flush = Task { await client.flush() } + await Task.yield() + #expect(await client.shutdown(discardPending: true)) + client.start() + #expect(await client.flush() == false) + #expect(await flush.value == false) + } +} + +private func stringAttributes(_ attributes: [[String: Any]]) -> [String: String] { + Dictionary(uniqueKeysWithValues: attributes.compactMap { attribute in + guard + let key = attribute["key"] as? String, + let value = attribute["value"] as? [String: Any], + let stringValue = value["stringValue"] as? String + else { return nil } + return (key, stringValue) + }) +} + +private func boolAttributes(_ attributes: [[String: Any]]) -> [String: Bool] { + Dictionary(uniqueKeysWithValues: attributes.compactMap { attribute in + guard + let key = attribute["key"] as? String, + let value = attribute["value"] as? [String: Any], + let boolValue = value["boolValue"] as? Bool + else { return nil } + return (key, boolValue) + }) +} + +private func successfulTransport(recorder: RequestRecorder) -> CheckoutKitTelemetry.Transport { + { request in + recorder.record(request) + return HTTPURLResponse(url: request.url!, statusCode: 200, httpVersion: nil, headerFields: nil)! + } +} + +private enum TestError: Error { + case unavailable +} + +private final class RequestRecorder: @unchecked Sendable { + private let lock = NSLock() + private var storedRequest: URLRequest? + + var request: URLRequest? { + lock.lock() + defer { lock.unlock() } + return storedRequest + } + + func record(_ request: URLRequest) { + lock.lock() + storedRequest = request + lock.unlock() + } +} + +private final class LockedTimes: @unchecked Sendable { + private let lock = NSLock() + private var values: [UInt64] + + init(_ values: [UInt64]) { + self.values = values + } + + func next() -> UInt64 { + lock.lock() + defer { lock.unlock() } + return values.removeFirst() + } +} + +private final class LockedBool: @unchecked Sendable { + private let lock = NSLock() + private var storedValue: Bool + + init(_ value: Bool) { + storedValue = value + } + + var value: Bool { + lock.lock() + defer { lock.unlock() } + return storedValue + } + + func set(_ value: Bool) { + lock.lock() + storedValue = value + lock.unlock() + } +} + +private final class LockedInt: @unchecked Sendable { + private let lock = NSLock() + private var storedValue: Int + + init(_ value: Int) { + storedValue = value + } + + var value: Int { + lock.lock() + defer { lock.unlock() } + return storedValue + } + + func increment() { + lock.lock() + storedValue += 1 + lock.unlock() + } +} diff --git a/telemetry/languages/typescript/package.json b/telemetry/languages/typescript/package.json new file mode 100644 index 000000000..e0b62ee2f --- /dev/null +++ b/telemetry/languages/typescript/package.json @@ -0,0 +1,24 @@ +{ + "name": "@shopify/checkout-kit-telemetry", + "version": "0.0.0", + "private": true, + "type": "module", + "main": "src/index.ts", + "types": "src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "test": "vitest run", + "typecheck": "tsc --noEmit", + "lint": "oxlint --report-unused-disable-directives --max-warnings 0 src test" + }, + "dependencies": { + "@shopify/checkout-kit-protocol": "workspace:*" + }, + "devDependencies": { + "oxlint": "^1.62.0", + "typescript": "5.9.3", + "vitest": "^4.1.0" + } +} diff --git a/telemetry/languages/typescript/src/client.ts b/telemetry/languages/typescript/src/client.ts new file mode 100644 index 000000000..28dca3f27 --- /dev/null +++ b/telemetry/languages/typescript/src/client.ts @@ -0,0 +1,266 @@ +import { + buildOtlpPayload, + DEFAULT_ENDPOINT, + type Attributes, + type Measurement, +} from './otlp'; +import {toProtocolMethod} from './protocol-method'; +import type { + CheckoutKitTelemetry as CheckoutKitTelemetryClient, + CheckoutKitTelemetryTestingOptions, + FlushOptions, + ShutdownOptions, + TelemetryErrorMetric, + TelemetryFetch, + TelemetryNavigationDurationMetric, + TelemetryNavigationRetryMetric, + TelemetryPlatform, + TelemetryProtocolDecodeErrorMetric, + TelemetryProduct, +} from './types'; + +const DEFAULT_EXPORT_INTERVAL_MS = 60_000; +const DEFAULT_MAX_PENDING_MEASUREMENTS = 128; +const MAX_EXPORT_BACKOFF_MS = 15 * 60_000; +const MAX_EXPORT_BACKOFF_EXPONENT = 4; + +class DefaultCheckoutKitTelemetry implements CheckoutKitTelemetryClient { + readonly #sdkVersion: string; + readonly #product: TelemetryProduct; + readonly #platform: TelemetryPlatform; + readonly #endpoint: string; + readonly #exportIntervalMs: number; + readonly #maxPendingMeasurements: number; + readonly #fetch: TelemetryFetch; + readonly #now: () => bigint; + + #measurements: Measurement[] = []; + #timer: ReturnType | undefined; + #flushInProgress: Promise | undefined; + #consecutiveExportFailures = 0; + #nextExportAllowedAtMs = 0; + #activeRequest: AbortController | undefined; + #stopped = false; + + constructor(options: CheckoutKitTelemetryTestingOptions) { + this.#sdkVersion = options.sdkVersion; + this.#product = options.product ?? 'checkout_kit'; + this.#platform = options.platform ?? 'web'; + this.#endpoint = options.endpoint ?? DEFAULT_ENDPOINT; + this.#exportIntervalMs = + options.exportIntervalMs ?? DEFAULT_EXPORT_INTERVAL_MS; + this.#maxPendingMeasurements = Math.max( + 1, + options.maxPendingMeasurements ?? DEFAULT_MAX_PENDING_MEASUREMENTS, + ); + this.#fetch = options.fetch ?? globalThis.fetch.bind(globalThis); + this.#now = + options.now ?? (() => BigInt(Date.now()) * BigInt(1_000_000)); + } + + start(): void { + if (this.#stopped || this.#timer || this.#exportIntervalMs <= 0) { + return; + } + this.#timer = setInterval(() => void this.flush(), this.#exportIntervalMs); + } + + recordError(metric: TelemetryErrorMetric): void { + this.#recordCounter('checkout_kit_error', this.#attributes({ + category: metric.category, + stage: metric.stage, + code: metric.code, + retryable: metric.retryable, + is_retry: metric.isRetry ?? false, + })); + } + + recordProtocolDecodeError(metric: TelemetryProtocolDecodeErrorMetric): void { + this.#recordCounter('checkout_kit_protocol_decode_error', this.#attributes({ + method: toProtocolMethod(metric.method), + failure_type: metric.failureType, + })); + } + + recordNavigationRetry(metric: TelemetryNavigationRetryMetric): void { + this.#recordCounter('checkout_kit_navigation_retry', this.#attributes({ + reason: metric.reason, + result: metric.result, + })); + } + + recordNavigationDuration(metric: TelemetryNavigationDurationMetric): void { + if (!Number.isFinite(metric.milliseconds) || metric.milliseconds < 0) return; + this.#record({ + type: 'histogram', + name: 'checkout_kit_navigation_duration_ms', + value: metric.milliseconds, + unit: 'ms', + attributes: this.#attributes({ + result: metric.result, + preloaded: metric.preloaded, + }), + timeUnixNano: this.#now(), + }); + } + + flush(options: FlushOptions = {}, ignoreBackoff = false): Promise { + if (this.#stopped) return Promise.resolve(false); + const inFlight = this.#flushInProgress; + if (inFlight) { + if (this.#measurements.length === 0) return inFlight; + // A keepalive flush cannot wait behind an in-flight export: the page + // may be torn down before that request settles. Start it right away + // alongside the ordinary export. + if (options.keepalive === true) { + const measurements = this.#measurements; + this.#measurements = []; + return this.#send(measurements, options); + } + return inFlight.then(async (inFlightSucceeded) => { + if (this.#stopped) return false; + const queuedSucceeded = await this.flush(options, ignoreBackoff); + return inFlightSucceeded && queuedSucceeded; + }); + } + if (this.#measurements.length === 0) { + return Promise.resolve(true); + } + // A keepalive flush is a page-terminal moment (pagehide/unload): skipping + // it because of backoff would silently drop the buffered measurements. + const bypassBackoff = ignoreBackoff || options.keepalive === true; + if (!bypassBackoff && Date.now() < this.#nextExportAllowedAtMs) { + return Promise.resolve(false); + } + const measurements = this.#measurements; + this.#measurements = []; + + this.#flushInProgress = this.#send(measurements, options).finally(() => { + this.#flushInProgress = undefined; + }); + return this.#flushInProgress; + } + + async shutdown(options: ShutdownOptions = {}): Promise { + if (this.#stopped) return true; + if (this.#timer) { + clearInterval(this.#timer); + this.#timer = undefined; + } + if (options.discardPending) { + this.#stopped = true; + this.#measurements = []; + this.#activeRequest?.abort(); + if (this.#flushInProgress) await this.#flushInProgress; + return true; + } + + const inFlight = this.#flushInProgress; + const inFlightSucceeded = inFlight ? await inFlight : true; + const flushed = await this.flush(options, true); + this.#stopped = true; + return inFlightSucceeded && flushed; + } + + #recordCounter(name: string, attributes: Attributes): void { + this.#record({ + type: 'counter', + name, + attributes, + timeUnixNano: this.#now(), + }); + } + + #attributes(attributes: Attributes): Attributes { + return { + ...attributes, + product: this.#product, + platform: this.#platform, + }; + } + + #record(measurement: Measurement): void { + if (this.#stopped) return; + if (this.#measurements.length >= this.#maxPendingMeasurements) return; + this.#measurements.push(measurement); + } + + async #send( + measurements: Measurement[], + options: FlushOptions, + ): Promise { + const controller = new AbortController(); + // A keepalive request must survive page teardown, so it is never the + // abortable active request; it must also not displace an ordinary + // export that shutdown may still need to abort. + if (options.keepalive !== true) { + this.#activeRequest = controller; + } + try { + const response = await this.#fetch(this.#endpoint, { + method: 'POST', + headers: {'content-type': 'application/json'}, + body: JSON.stringify( + buildOtlpPayload({ + sdkVersion: this.#sdkVersion, + measurements, + }), + ), + keepalive: options.keepalive ?? false, + referrerPolicy: 'no-referrer', + signal: controller.signal, + }); + const succeeded = response.ok; + if (!this.#stopped) { + if (!succeeded) this.#restoreMeasurements(measurements); + this.#updateExportBackoff(succeeded); + } + return succeeded; + } catch { + if (!this.#stopped) { + this.#restoreMeasurements(measurements); + this.#updateExportBackoff(false); + } + return false; + } finally { + if (this.#activeRequest === controller) this.#activeRequest = undefined; + } + } + + #updateExportBackoff(succeeded: boolean): void { + if (succeeded) { + this.#consecutiveExportFailures = 0; + this.#nextExportAllowedAtMs = 0; + return; + } + this.#consecutiveExportFailures += 1; + const exponent = Math.min( + this.#consecutiveExportFailures - 1, + MAX_EXPORT_BACKOFF_EXPONENT, + ); + const backoff = Math.min( + this.#exportIntervalMs * 2 ** exponent, + MAX_EXPORT_BACKOFF_MS, + ); + this.#nextExportAllowedAtMs = Date.now() + backoff; + } + + #restoreMeasurements(measurements: Measurement[]): void { + this.#measurements = [...measurements, ...this.#measurements].slice( + 0, + this.#maxPendingMeasurements, + ); + } +} + +export function createCheckoutKitTelemetry( + sdkVersion: string, +): CheckoutKitTelemetryClient { + return new DefaultCheckoutKitTelemetry({sdkVersion}); +} + +export function createCheckoutKitTelemetryForTesting( + options: CheckoutKitTelemetryTestingOptions, +): CheckoutKitTelemetryClient { + return new DefaultCheckoutKitTelemetry(options); +} diff --git a/telemetry/languages/typescript/src/index.ts b/telemetry/languages/typescript/src/index.ts new file mode 100644 index 000000000..f829ef7af --- /dev/null +++ b/telemetry/languages/typescript/src/index.ts @@ -0,0 +1,11 @@ +export { + createCheckoutKitTelemetry, + createCheckoutKitTelemetryForTesting, +} from './client'; +export type { + CheckoutKitTelemetry, + TelemetryPlatform, + TelemetryProduct, + TelemetryProtocolMethod, +} from './types'; +export {toProtocolMethod} from './protocol-method'; diff --git a/telemetry/languages/typescript/src/otlp.ts b/telemetry/languages/typescript/src/otlp.ts new file mode 100644 index 000000000..4a0b9f9d1 --- /dev/null +++ b/telemetry/languages/typescript/src/otlp.ts @@ -0,0 +1,157 @@ +export const DEFAULT_ENDPOINT = + 'https://otlp-http-production.shopifysvc.com/v1/metrics'; + +export const INSTRUMENTATION_NAME = 'checkout-kit-telemetry'; + +export type AttributeValue = string | boolean; +export type Attributes = Record; + +export interface CounterMeasurement { + type: 'counter'; + name: string; + attributes: Attributes; + timeUnixNano: bigint; +} + +export interface HistogramMeasurement { + type: 'histogram'; + name: string; + value: number; + unit: string; + attributes: Attributes; + timeUnixNano: bigint; +} + +export type Measurement = CounterMeasurement | HistogramMeasurement; + +interface PayloadOptions { + sdkVersion: string; + measurements: Measurement[]; +} + +const HISTOGRAM_BOUNDS = [100, 250, 500, 1_000, 2_500, 5_000, 10_000, 30_000]; + +export function buildOtlpPayload({ + sdkVersion, + measurements, +}: PayloadOptions): Record { + const metrics = groupMeasurements(measurements).map(buildMetric); + + return { + resourceMetrics: [ + { + resource: { + attributes: encodeAttributes({ + 'service.name': 'checkout-kit', + 'service.version': sdkVersion, + 'telemetry.sdk.language': 'webjs', + 'telemetry.sdk.name': INSTRUMENTATION_NAME, + 'telemetry.sdk.version': sdkVersion, + }), + }, + scopeMetrics: [ + { + scope: { + name: INSTRUMENTATION_NAME, + version: sdkVersion, + }, + metrics, + }, + ], + }, + ], + }; +} + +function groupMeasurements(measurements: Measurement[]): Measurement[][] { + const groups = new Map(); + + for (const measurement of measurements) { + const key = JSON.stringify([ + measurement.type, + measurement.name, + sortedAttributeEntries(measurement.attributes), + ]); + const group = groups.get(key); + if (group) { + group.push(measurement); + } else { + groups.set(key, [measurement]); + } + } + + return [...groups.values()].sort((left, right) => + left[0]!.name.localeCompare(right[0]!.name), + ); +} + +function buildMetric(group: Measurement[]): Record { + const first = group[0]!; + const startTimeUnixNano = group[0]!.timeUnixNano.toString(); + const timeUnixNano = group[group.length - 1]!.timeUnixNano.toString(); + const attributes = encodeAttributes(first.attributes); + + if (first.type === 'counter') { + return { + name: first.name, + sum: { + aggregationTemporality: 1, + isMonotonic: true, + dataPoints: [ + { + attributes, + asInt: group.length.toString(), + startTimeUnixNano, + timeUnixNano, + }, + ], + }, + }; + } + + const values = group.map((measurement) => + measurement.type === 'histogram' ? measurement.value : 0, + ); + const bucketCounts = Array.from({length: HISTOGRAM_BOUNDS.length + 1}, () => 0); + for (const value of values) { + const index = HISTOGRAM_BOUNDS.findIndex((bound) => value <= bound); + bucketCounts[index === -1 ? bucketCounts.length - 1 : index]! += 1; + } + + return { + name: first.name, + unit: first.unit, + histogram: { + aggregationTemporality: 1, + dataPoints: [ + { + attributes, + bucketCounts: bucketCounts.map(String), + count: values.length.toString(), + explicitBounds: HISTOGRAM_BOUNDS, + min: Math.min(...values), + max: Math.max(...values), + sum: values.reduce((sum, value) => sum + value, 0), + startTimeUnixNano, + timeUnixNano, + }, + ], + }, + }; +} + +function encodeAttributes(attributes: Attributes) { + return sortedAttributeEntries(attributes).map(([key, value]) => ({ + key, + value: + typeof value === 'boolean' + ? {boolValue: value} + : {stringValue: value}, + })); +} + +function sortedAttributeEntries(attributes: Attributes) { + return Object.entries(attributes).sort(([left], [right]) => + left.localeCompare(right), + ); +} diff --git a/telemetry/languages/typescript/src/protocol-method.ts b/telemetry/languages/typescript/src/protocol-method.ts new file mode 100644 index 000000000..6509c6dd7 --- /dev/null +++ b/telemetry/languages/typescript/src/protocol-method.ts @@ -0,0 +1,9 @@ +import {embeddedCheckoutMethods} from '@shopify/checkout-kit-protocol'; + +import type {TelemetryProtocolMethod} from './types'; + +export function toProtocolMethod(method: string): TelemetryProtocolMethod { + return embeddedCheckoutMethods.has(method) + ? (method as TelemetryProtocolMethod) + : 'unknown'; +} diff --git a/telemetry/languages/typescript/src/types.ts b/telemetry/languages/typescript/src/types.ts new file mode 100644 index 000000000..7435e7c34 --- /dev/null +++ b/telemetry/languages/typescript/src/types.ts @@ -0,0 +1,121 @@ +import type { + CheckoutProtocolCatalogMethod, + CheckoutProtocolRequestMethod, +} from '@shopify/checkout-kit-protocol'; + +export type TelemetryErrorCategory = + | 'http' + | 'navigation' + | 'protocol' + | 'render_process' + | 'unknown'; + +export type TelemetryErrorStage = + | 'initialization' + | 'load' + | 'message' + | 'presentation'; + +export type TelemetryErrorCode = + | '4xx' + | '5xx' + | 'cancelled' + | 'connection_lost' + | 'cannot_connect' + | 'dns' + | 'timeout' + | 'unknown'; + +export type TelemetryProtocolMethod = + | CheckoutProtocolCatalogMethod + | CheckoutProtocolRequestMethod + | 'unknown'; + +export type TelemetryDecodeFailureType = + | 'envelope' + | 'params' + | 'serialization' + | 'unknown'; + +export type TelemetryNavigationRetryReason = + | 'timeout' + | 'connection_lost' + | 'cannot_connect' + | 'dns' + | 'unknown'; + +export type TelemetryNavigationRetryResult = + | 'started' + | 'failed' + | 'not_attempted'; + +export type TelemetryNavigationDurationResult = 'success' | 'failure'; + +export type TelemetryProduct = + | 'checkout_kit' + | 'accelerated_checkouts' + | 'customer_auth'; + +export type TelemetryPlatform = 'web'; + +export interface TelemetryErrorMetric { + category: TelemetryErrorCategory; + stage: TelemetryErrorStage; + code: TelemetryErrorCode; + retryable: boolean; + isRetry?: boolean; +} + +export interface TelemetryProtocolDecodeErrorMetric { + method: TelemetryProtocolMethod; + failureType: TelemetryDecodeFailureType; +} + +export interface TelemetryNavigationRetryMetric { + reason: TelemetryNavigationRetryReason; + result: TelemetryNavigationRetryResult; +} + +export interface TelemetryNavigationDurationMetric { + milliseconds: number; + result: TelemetryNavigationDurationResult; + preloaded: boolean; +} + +export interface CheckoutKitTelemetry { + start(): void; + recordError(metric: TelemetryErrorMetric): void; + recordProtocolDecodeError(metric: TelemetryProtocolDecodeErrorMetric): void; + recordNavigationRetry(metric: TelemetryNavigationRetryMetric): void; + recordNavigationDuration(metric: TelemetryNavigationDurationMetric): void; + flush(options?: FlushOptions): Promise; + shutdown(options?: ShutdownOptions): Promise; +} + +interface TelemetryResponse { + ok: boolean; +} + +export type TelemetryFetch = ( + url: string, + init: RequestInit, +) => Promise; + +export interface CheckoutKitTelemetryTestingOptions { + sdkVersion: string; + product?: TelemetryProduct; + platform?: TelemetryPlatform; + endpoint?: string; + exportIntervalMs?: number; + maxPendingMeasurements?: number; + fetch?: TelemetryFetch; + now?: () => bigint; +} + +export interface FlushOptions { + keepalive?: boolean; +} + +export interface ShutdownOptions extends FlushOptions { + discardPending?: boolean; +} diff --git a/telemetry/languages/typescript/test/client.test.ts b/telemetry/languages/typescript/test/client.test.ts new file mode 100644 index 000000000..3275cbcc5 --- /dev/null +++ b/telemetry/languages/typescript/test/client.test.ts @@ -0,0 +1,357 @@ +import {describe, expect, it, vi} from 'vitest'; + +import {createCheckoutKitTelemetryForTesting} from '../src/client'; + +describe('CheckoutKitTelemetry', () => { + it('aggregates matching counters into an OTLP delta sum', async () => { + const fetch = vi.fn().mockResolvedValue({ok: true}); + const times = [BigInt(1_000_000), BigInt(2_000_000)]; + const telemetry = createCheckoutKitTelemetryForTesting({ + sdkVersion: '1.2.3', + product: 'accelerated_checkouts', + fetch, + now: () => times.shift()!, + }); + + telemetry.recordError({ + category: 'http', + stage: 'load', + code: '5xx', + retryable: true, + isRetry: true, + }); + telemetry.recordError({ + category: 'http', + stage: 'load', + code: '5xx', + retryable: true, + isRetry: true, + }); + + await expect(telemetry.flush()).resolves.toBe(true); + + expect(fetch.mock.calls[0]![1].referrerPolicy).toBe('no-referrer'); + const body = JSON.parse(fetch.mock.calls[0]![1].body as string); + const resourceAttributes = Object.fromEntries( + body.resourceMetrics[0].resource.attributes.map( + ({key, value}: {key: string; value: {stringValue: string}}) => [ + key, + value.stringValue, + ], + ), + ); + expect(resourceAttributes).toEqual({ + 'service.name': 'checkout-kit', + 'service.version': '1.2.3', + 'telemetry.sdk.language': 'webjs', + 'telemetry.sdk.name': 'checkout-kit-telemetry', + 'telemetry.sdk.version': '1.2.3', + }); + const metric = body.resourceMetrics[0].scopeMetrics[0].metrics[0]; + expect(metric.name).toBe('checkout_kit_error'); + expect(metric.sum).toMatchObject({ + aggregationTemporality: 1, + isMonotonic: true, + dataPoints: [ + { + asInt: '2', + startTimeUnixNano: '1000000', + timeUnixNano: '2000000', + }, + ], + }); + expect(metric.sum.dataPoints[0].attributes).toContainEqual({ + key: 'product', + value: {stringValue: 'accelerated_checkouts'}, + }); + expect(metric.sum.dataPoints[0].attributes).toContainEqual({ + key: 'platform', + value: {stringValue: 'web'}, + }); + expect(metric.sum.dataPoints[0].attributes).not.toContainEqual( + expect.objectContaining({key: 'integration'}), + ); + expect(metric.sum.dataPoints[0].attributes).toContainEqual({ + key: 'is_retry', + value: {boolValue: true}, + }); + }); + + it('never includes raw error or protocol data in its API payload', async () => { + const fetch = vi.fn().mockResolvedValue({ok: true}); + const telemetry = createCheckoutKitTelemetryForTesting({ + sdkVersion: '1.2.3', + fetch, + now: () => BigInt(1), + }); + + telemetry.recordProtocolDecodeError({ + method: 'ec.start', + failureType: 'params', + }); + await telemetry.flush(); + + const body = fetch.mock.calls[0]![1].body as string; + expect(body).not.toContain('checkoutUrl'); + expect(body).not.toContain('error.message'); + expect(body).toContain('checkout_kit_protocol_decode_error'); + }); + + it('bounds pending measurements and isolates export failures', async () => { + const fetch = vi.fn().mockRejectedValue(new Error('network unavailable')); + const telemetry = createCheckoutKitTelemetryForTesting({ + sdkVersion: '1.2.3', + maxPendingMeasurements: 1, + fetch, + now: () => BigInt(1), + }); + + telemetry.recordNavigationRetry({reason: 'timeout', result: 'started'}); + telemetry.recordNavigationRetry({reason: 'dns', result: 'failed'}); + + await expect(telemetry.flush()).resolves.toBe(false); + const body = JSON.parse(fetch.mock.calls[0]![1].body as string); + expect(body.resourceMetrics[0].scopeMetrics[0].metrics).toHaveLength(1); + }); + + it('clamps pending measurement capacity to one', async () => { + const fetch = vi.fn().mockResolvedValue({ok: true}); + const telemetry = createCheckoutKitTelemetryForTesting({ + sdkVersion: '1.2.3', + maxPendingMeasurements: 0, + fetch, + now: () => BigInt(1), + }); + + telemetry.recordNavigationRetry({reason: 'timeout', result: 'started'}); + telemetry.recordNavigationRetry({reason: 'dns', result: 'failed'}); + + await telemetry.flush(); + const body = JSON.parse(fetch.mock.calls[0]![1].body as string); + const points = body.resourceMetrics[0].scopeMetrics[0].metrics[0].sum.dataPoints; + expect(points[0].asInt).toBe('1'); + }); + + it('normalizes unsupported protocol methods at runtime', async () => { + const fetch = vi.fn().mockResolvedValue({ok: true}); + const telemetry = createCheckoutKitTelemetryForTesting({ + sdkVersion: '1.2.3', + fetch, + now: () => BigInt(1), + }); + + telemetry.recordProtocolDecodeError({ + method: 'attacker-controlled' as 'ec.start', + failureType: 'params', + }); + await telemetry.flush(); + + expect(fetch.mock.calls[0]![1].body).toContain('unknown'); + expect(fetch.mock.calls[0]![1].body).not.toContain('attacker-controlled'); + }); + + it('accepts methods added to the generated protocol catalog', async () => { + const fetch = vi.fn().mockResolvedValue({ok: true}); + const telemetry = createCheckoutKitTelemetryForTesting({ + sdkVersion: '1.2.3', + fetch, + now: () => BigInt(1), + }); + + telemetry.recordProtocolDecodeError({ + method: 'ec.buyer.change', + failureType: 'params', + }); + await telemetry.flush(); + + expect(fetch.mock.calls[0]![1].body).toContain('ec.buyer.change'); + }); + + it('awaits an in-flight export during shutdown', async () => { + let resolveFetch: ((value: {ok: boolean}) => void) | undefined; + const fetch = vi.fn().mockImplementation( + () => new Promise<{ok: boolean}>((resolve) => (resolveFetch = resolve)), + ); + const telemetry = createCheckoutKitTelemetryForTesting({ + sdkVersion: '1.2.3', + fetch, + now: () => BigInt(1), + }); + telemetry.recordError({ + category: 'http', + stage: 'load', + code: '5xx', + retryable: true, + }); + + void telemetry.flush(); + const shutdown = telemetry.shutdown(); + let completed = false; + void shutdown.then(() => (completed = true)); + await Promise.resolve(); + expect(completed).toBe(false); + resolveFetch?.({ok: true}); + await expect(shutdown).resolves.toBe(true); + }); + + it('discards pending work and makes lifecycle calls safe after shutdown', async () => { + const fetch = vi.fn().mockResolvedValue({ok: true}); + const telemetry = createCheckoutKitTelemetryForTesting({ + sdkVersion: '1.2.3', + fetch, + now: () => BigInt(1), + }); + telemetry.recordError({ + category: 'http', + stage: 'load', + code: '5xx', + retryable: true, + }); + + await expect(telemetry.shutdown({discardPending: true})).resolves.toBe(true); + telemetry.start(); + await expect(telemetry.flush()).resolves.toBe(false); + await expect(telemetry.shutdown()).resolves.toBe(true); + expect(fetch).not.toHaveBeenCalled(); + }); + + it('backs off after an export failure', async () => { + vi.useFakeTimers(); + vi.setSystemTime(1_000); + const fetch = vi.fn().mockResolvedValue({ok: false}); + const telemetry = createCheckoutKitTelemetryForTesting({ + sdkVersion: '1.2.3', + exportIntervalMs: 1_000, + fetch, + now: () => BigInt(1), + }); + + telemetry.recordError({ + category: 'http', + stage: 'load', + code: '5xx', + retryable: true, + }); + await expect(telemetry.flush()).resolves.toBe(false); + telemetry.recordError({ + category: 'http', + stage: 'load', + code: '5xx', + retryable: true, + }); + await expect(telemetry.flush()).resolves.toBe(false); + + expect(fetch).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it('bypasses backoff for the final shutdown flush', async () => { + const fetch = vi.fn().mockResolvedValueOnce({ok: false}).mockResolvedValue({ok: true}); + const telemetry = createCheckoutKitTelemetryForTesting({ + sdkVersion: '1.2.3', + fetch, + now: () => BigInt(1), + }); + telemetry.recordError({category: 'http', stage: 'load', code: '5xx', retryable: true}); + await telemetry.flush(); + telemetry.recordError({category: 'http', stage: 'load', code: '5xx', retryable: true}); + + await expect(telemetry.shutdown()).resolves.toBe(true); + expect(fetch).toHaveBeenCalledTimes(2); + const finalPayload = JSON.parse(fetch.mock.calls[1]![1].body as string); + expect(finalPayload.resourceMetrics[0].scopeMetrics[0].metrics[0].sum.dataPoints[0].asInt).toBe('2'); + }); + + it('bypasses backoff for keepalive flushes', async () => { + const fetch = vi.fn().mockResolvedValueOnce({ok: false}).mockResolvedValue({ok: true}); + const telemetry = createCheckoutKitTelemetryForTesting({ + sdkVersion: '1.2.3', + fetch, + now: () => BigInt(1), + }); + telemetry.recordError({category: 'http', stage: 'load', code: '5xx', retryable: true}); + await telemetry.flush(); + + await expect(telemetry.flush()).resolves.toBe(false); + await expect(telemetry.flush({keepalive: true})).resolves.toBe(true); + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls[1]![1].keepalive).toBe(true); + }); + + it('starts a keepalive export immediately when an ordinary export is active', async () => { + let resolveFirstFetch: ((value: {ok: boolean}) => void) | undefined; + const fetch = vi + .fn() + .mockImplementationOnce( + () => + new Promise<{ok: boolean}>((resolve) => { + resolveFirstFetch = resolve; + }), + ) + .mockResolvedValue({ok: true}); + const telemetry = createCheckoutKitTelemetryForTesting({ + sdkVersion: '1.2.3', + fetch, + now: () => BigInt(1), + }); + + telemetry.recordError({ + category: 'http', + stage: 'load', + code: '5xx', + retryable: true, + }); + const ordinaryFlush = telemetry.flush(); + telemetry.recordError({ + category: 'protocol', + stage: 'message', + code: 'unknown', + retryable: false, + }); + const terminalFlush = telemetry.flush({keepalive: true}); + + expect(fetch).toHaveBeenCalledTimes(2); + expect(fetch.mock.calls[0]![1].keepalive).toBe(false); + expect(fetch.mock.calls[1]![1].keepalive).toBe(true); + const keepalivePayload = JSON.parse(fetch.mock.calls[1]![1].body as string); + expect( + keepalivePayload.resourceMetrics[0].scopeMetrics[0].metrics[0].sum.dataPoints[0].asInt, + ).toBe('1'); + await expect(terminalFlush).resolves.toBe(true); + + resolveFirstFetch?.({ok: true}); + await expect(ordinaryFlush).resolves.toBe(true); + }); + + it('records finite non-negative navigation durations only', async () => { + const fetch = vi.fn().mockResolvedValue({ok: true}); + const telemetry = createCheckoutKitTelemetryForTesting({ + sdkVersion: '1.2.3', + fetch, + now: () => BigInt(1), + }); + + telemetry.recordNavigationDuration({ + milliseconds: Number.NaN, + result: 'failure', + preloaded: false, + }); + telemetry.recordNavigationDuration({ + milliseconds: 450, + result: 'success', + preloaded: false, + }); + + await telemetry.flush({keepalive: true}); + const request = fetch.mock.calls[0]![1]; + const body = JSON.parse(request.body as string); + const metric = body.resourceMetrics[0].scopeMetrics[0].metrics[0]; + expect(request.keepalive).toBe(true); + expect(metric.histogram.dataPoints[0]).toMatchObject({ + count: '1', + min: 450, + max: 450, + sum: 450, + }); + }); +}); diff --git a/telemetry/languages/typescript/tsconfig.json b/telemetry/languages/typescript/tsconfig.json new file mode 100644 index 000000000..8e9f59505 --- /dev/null +++ b/telemetry/languages/typescript/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022", "DOM"], + "module": "Preserve", + "moduleResolution": "Bundler", + "isolatedModules": true, + "strict": true, + "noFallthroughCasesInSwitch": true, + "noImplicitReturns": true, + "noUncheckedIndexedAccess": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true + }, + "include": ["src/**/*", "test/**/*"] +}