From 3cf01f28cf4eab7edced8180e302c8e41451eb13 Mon Sep 17 00:00:00 2001 From: tiagocandido Date: Wed, 19 Aug 2026 15:00:35 +0200 Subject: [PATCH] Instrument web checkout failures --- platforms/web/README.md | 24 ++++ platforms/web/src/checkout-protocol.test.ts | 122 +++++++++++++++----- platforms/web/src/checkout-window.test.ts | 19 ++- platforms/web/src/checkout.test.ts | 82 +++++++++++++ platforms/web/src/checkout.ts | 116 ++++++++++++++++--- platforms/web/src/checkout.types.ts | 9 ++ platforms/web/src/telemetry.test-helpers.ts | 27 +++++ platforms/web/src/telemetry.ts | 26 +++++ platforms/web/src/version.ts | 3 + platforms/web/vite.config.ts | 1 + platforms/web/vitest.setup.ts | 5 + 11 files changed, 392 insertions(+), 42 deletions(-) create mode 100644 platforms/web/src/telemetry.test-helpers.ts create mode 100644 platforms/web/src/telemetry.ts create mode 100644 platforms/web/src/version.ts create mode 100644 platforms/web/vitest.setup.ts diff --git a/platforms/web/README.md b/platforms/web/README.md index 4a0a5d971..0d0ef8b59 100644 --- a/platforms/web/README.md +++ b/platforms/web/README.md @@ -36,6 +36,7 @@ Check out our blog to - [`target`](#target) - [`appearance`](#appearance) - [`log-level`](#log-level) + - [`telemetry-enabled`](#telemetry-enabled) - [Popup dimensions](#popup-dimensions) - [Overlay scrim](#overlay-scrim) - [Checkout lifecycle](#checkout-lifecycle) @@ -224,6 +225,7 @@ declare module 'react' { target?: string; appearance?: string; 'log-level'?: 'debug' | 'warn' | 'error' | 'none'; + 'telemetry-enabled'?: 'true' | 'false'; }; } } @@ -428,6 +430,28 @@ Wildcard entries match subdomains only, not the apex domain. For example, > Setting `allowed-origins="*"` disables the message-origin allowlist. Use it > only for controlled debugging, never in production. +### `telemetry-enabled` + +Controls anonymous diagnostic metrics sent to Shopify. Telemetry is enabled by +default. Checkout Kit reports bounded counts for checkout errors and protocol +decoding failures, plus navigation duration histograms. Diagnostics never +include checkout URLs, message payloads, buyer data, or checkout, order, +customer, or shop identifiers. Set the attribute or property to `false` to opt +out; changing it at runtime also discards buffered measurements. + +On web, navigation duration starts when Checkout Kit opens the popup and ends +when checkout sends `ec.start`, because the host page cannot reliably observe +cross-origin checkout page-finish. `ec.start` means checkout is loaded and +interactive. + +```html + +``` + +```ts +checkout.telemetryEnabled = false; +``` + ### Popup dimensions When `target="popup"`, the popup is centered over the host window. Defaults diff --git a/platforms/web/src/checkout-protocol.test.ts b/platforms/web/src/checkout-protocol.test.ts index 3a0eaf885..4340e892b 100644 --- a/platforms/web/src/checkout-protocol.test.ts +++ b/platforms/web/src/checkout-protocol.test.ts @@ -1,18 +1,23 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EmbeddedCheckoutProtocol } from "@shopify/checkout-kit-protocol"; import type { CheckoutProtocolMessageMap, ErrorResponse, Message } from "./checkout.types"; import "./checkout-web-component"; import type { ShopifyCheckout } from "./checkout"; +import { mockTelemetry } from "./telemetry.test-helpers"; const EMBED_PROTOCOL_VERSION = EmbeddedCheckoutProtocol.specVersion; describe("", () => { + beforeEach(() => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 204 })); + }); + afterEach(() => { - vi.restoreAllMocks(); // Disconnect elements so their global message listeners do not leak // into tests in this file or another concurrently running suite. document.body.innerHTML = ""; + vi.restoreAllMocks(); }); describe("it subscribes to checkout-protocol events", () => { @@ -206,6 +211,33 @@ describe("", () => { expect(checkout.checkout).toEqual(decodeCheckout(payload)); expect(onStartSpy).toHaveBeenCalledOnce(); }); + + it("measures navigation from before the checkout window opens", async () => { + let now = 100; + vi.spyOn(performance, "now").mockImplementation(() => now); + const durationSpy = vi.spyOn(mockTelemetry(), "recordNavigationDuration"); + const checkout = renderCheckout({ target: "popup" }); + const mockCheckoutWindow = createMockWindow(); + vi.spyOn(window, "open").mockImplementation(() => { + now = 200; + return mockCheckoutWindow; + }); + vi.spyOn(HTMLDialogElement.prototype, "showModal").mockImplementation(() => {}); + vi.spyOn(HTMLDialogElement.prototype, "close").mockImplementation(() => {}); + + checkout.open(); + now = 300; + simulateProtocolMessageEvent(checkout, "ec.start", makeCheckoutPayload(), { + source: mockCheckoutWindow, + }); + await flushProtocolDispatch(); + + expect(durationSpy).toHaveBeenCalledWith({ + milliseconds: 200, + result: "success", + preloaded: false, + }); + }); }); describe("ec.complete", () => { @@ -227,6 +259,9 @@ describe("", () => { describe("ec.error", () => { it("updates the error property and dispatches an ec.error event", async () => { + const telemetry = mockTelemetry(); + const telemetrySpy = vi.spyOn(telemetry, "recordError"); + const durationSpy = vi.spyOn(telemetry, "recordNavigationDuration"); const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onErrorSpy = vi.fn(); const listenForEvent = waitForEvent(checkout, "ec.error", onErrorSpy); @@ -239,6 +274,18 @@ describe("", () => { expect(checkout.error).toEqual(decodeError(errorParams)); expect(onErrorSpy).toHaveBeenCalledOnce(); + expect(telemetrySpy).toHaveBeenCalledWith({ + category: "protocol", + stage: "message", + code: "unknown", + retryable: false, + isRetry: false, + }); + expect(durationSpy).toHaveBeenCalledWith({ + milliseconds: expect.any(Number), + result: "failure", + preloaded: false, + }); }); it("ignores the old ec.error shape with ucp and messages directly in params", async () => { @@ -264,45 +311,37 @@ describe("", () => { expect(onErrorSpy).not.toHaveBeenCalled(); }); - it("auto-closes when any message has severity 'unrecoverable'", async () => { - const { checkout, mockCheckoutWindow } = openPopupCheckout(); - const errorOrder: string[] = []; - checkout.addEventListener("ec.error", () => errorOrder.push("error")); - checkout.addEventListener("ec.close", () => errorOrder.push("close")); - - simulateProtocolMessageEvent( - checkout, - "ec.error", - makeErrorParams({ severity: "unrecoverable" }), - { source: mockCheckoutWindow }, - ); - await flushProtocolDispatch(); - - expect(errorOrder).toStrictEqual(["error", "close"]); - }); - - const NON_FATAL_SEVERITIES: ReadonlyArray = [ + const ERROR_SEVERITIES: ReadonlyArray = [ + "unrecoverable", "recoverable", "requires_buyer_input", "requires_buyer_review", ]; - it.each(NON_FATAL_SEVERITIES)( - "does not auto-close when severity is %s", + it.each(ERROR_SEVERITIES)( + "auto-closes when message severity is %s", async (severity: Message["severity"]) => { + const durationSpy = vi.spyOn(mockTelemetry(), "recordNavigationDuration"); const { checkout, mockCheckoutWindow } = openPopupCheckout(); - const closeSpy = vi.fn(); - checkout.addEventListener("ec.close", closeSpy); + const errorOrder: string[] = []; + checkout.addEventListener("ec.error", () => errorOrder.push("error")); + checkout.addEventListener("ec.close", () => errorOrder.push("close")); simulateProtocolMessageEvent(checkout, "ec.error", makeErrorParams({ severity }), { source: mockCheckoutWindow, }); await flushProtocolDispatch(); - expect(closeSpy).not.toHaveBeenCalled(); + expect(errorOrder).toStrictEqual(["error", "close"]); + expect(durationSpy).toHaveBeenCalledWith({ + milliseconds: expect.any(Number), + result: "failure", + preloaded: false, + }); }, ); it("does not crash when ec.error messages is not an array", async () => { + const durationSpy = vi.spyOn(mockTelemetry(), "recordNavigationDuration"); const { checkout, mockCheckoutWindow } = openPopupCheckout(); const onErrorSpy = vi.fn(); const closeSpy = vi.fn(); @@ -341,7 +380,12 @@ describe("", () => { expect(rejections).toEqual([]); expect(onErrorSpy).toHaveBeenCalledOnce(); - expect(closeSpy).not.toHaveBeenCalled(); + expect(closeSpy).toHaveBeenCalledOnce(); + expect(durationSpy).toHaveBeenCalledWith({ + milliseconds: expect.any(Number), + result: "failure", + preloaded: false, + }); }); }); @@ -1161,6 +1205,7 @@ describe("", () => { }); it("drops non-serializable messages without throwing", async () => { + const telemetrySpy = vi.spyOn(mockTelemetry(), "recordProtocolDecodeError"); const { checkout, mockCheckoutWindow } = openPopupCheckout({ "log-level": "warn" }); const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); const circularMessage: Record = { @@ -1180,6 +1225,31 @@ describe("", () => { expect(consoleWarnSpy).toHaveBeenCalledWith( expect.stringContaining("Dropped message because it could not be serialized"), ); + expect(telemetrySpy).toHaveBeenCalledWith({ + method: "unknown", + failureType: "serialization", + }); + }); + + it("does not record decode errors when telemetry is disabled", async () => { + const { checkout, mockCheckoutWindow } = openPopupCheckout({ + "log-level": "warn", + "telemetry-enabled": "false", + }); + const consoleWarnSpy = vi.spyOn(console, "warn").mockImplementation(() => {}); + const telemetrySpy = vi.spyOn(mockTelemetry(), "recordProtocolDecodeError"); + const circularMessage: Record = {}; + circularMessage.self = circularMessage; + + simulateRawMessageEvent(checkout, circularMessage, { + source: mockCheckoutWindow, + }); + await flushProtocolDispatch(); + + expect(consoleWarnSpy).toHaveBeenCalledWith( + expect.stringContaining("Dropped message because it could not be serialized"), + ); + expect(telemetrySpy).not.toHaveBeenCalled(); }); }); diff --git a/platforms/web/src/checkout-window.test.ts b/platforms/web/src/checkout-window.test.ts index 684254953..b27ea237a 100644 --- a/platforms/web/src/checkout-window.test.ts +++ b/platforms/web/src/checkout-window.test.ts @@ -1,9 +1,10 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { EmbeddedCheckoutProtocol } from "@shopify/checkout-kit-protocol"; import "./checkout-web-component"; import { DEFAULT_POPUP_WIDTH, DEFAULT_POPUP_HEIGHT } from "./checkout"; import type { ShopifyCheckout } from "./checkout"; +import { mockTelemetry } from "./telemetry.test-helpers"; const EMBED_PROTOCOL_VERSION = EmbeddedCheckoutProtocol.specVersion; @@ -22,11 +23,15 @@ function expectWindowOpenArgs(spy: { } describe("", () => { + beforeEach(() => { + vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(null, { status: 204 })); + }); + afterEach(() => { - vi.restoreAllMocks(); // Disconnect elements so their global message listeners do not leak // into tests in this file or another concurrently running suite. document.body.innerHTML = ""; + vi.restoreAllMocks(); }); describe("target", () => { @@ -230,12 +235,20 @@ describe("", () => { it("handles popup blocked scenario gracefully", () => { POPUP_TARGETS.forEach((target) => { + const telemetrySpy = vi.spyOn(mockTelemetry(), "recordError"); const checkout = renderCheckout({ target }); const windowOpenSpy = vi.spyOn(window, "open").mockReturnValue(null); checkout.open(); expect(windowOpenSpy).toHaveBeenCalled(); + expect(telemetrySpy).toHaveBeenCalledWith({ + category: "navigation", + stage: "presentation", + code: "unknown", + retryable: false, + isRetry: false, + }); // Should not throw error when popup is blocked }); }); @@ -269,6 +282,7 @@ describe("", () => { } as CSSStyleDeclaration); const closeEventSpy = vi.fn(); + const durationSpy = vi.spyOn(mockTelemetry(), "recordNavigationDuration"); checkout.addEventListener("ec.close", closeEventSpy); checkout.open(); @@ -278,6 +292,7 @@ describe("", () => { expect(mockPopup.close).toHaveBeenCalled(); expect(closeEventSpy).toHaveBeenCalled(); + expect(durationSpy).not.toHaveBeenCalled(); }); }); }); diff --git a/platforms/web/src/checkout.test.ts b/platforms/web/src/checkout.test.ts index 3064ac080..a780df28f 100644 --- a/platforms/web/src/checkout.test.ts +++ b/platforms/web/src/checkout.test.ts @@ -5,6 +5,12 @@ import { version } from "../package.json"; import "./checkout-web-component"; import { CK_VERSION } from "./checkout"; +import { + createTestTelemetry, + installTestTelemetryFactory, + mockTelemetry, +} from "./telemetry.test-helpers"; +import { overrideTelemetryFactoryForTesting } from "./telemetry"; const EMBED_PROTOCOL_VERSION = EmbeddedCheckoutProtocol.specVersion; @@ -22,6 +28,7 @@ function expectWindowOpenArgs(spy: { describe("", () => { afterEach(() => { vi.restoreAllMocks(); + installTestTelemetryFactory(); // Disconnect elements so their global message listeners do not leak // into tests in this file or another concurrently running suite. document.body.innerHTML = ""; @@ -122,6 +129,32 @@ describe("", () => { expect(checkout.hasAttribute("log-level")).toBe(false); }); }); + + describe("telemetryEnabled", () => { + it("defaults to true", () => { + const checkout = renderCheckout(); + + expect(checkout.telemetryEnabled).toBe(true); + }); + + it("reflects false between the property and attribute", () => { + const checkout = renderCheckout(); + + checkout.telemetryEnabled = false; + + expect(checkout.getAttribute("telemetry-enabled")).toBe("false"); + expect(checkout.telemetryEnabled).toBe(false); + }); + + it("resets to the enabled default when assigned undefined", () => { + const checkout = renderCheckout({ "telemetry-enabled": "false" }); + + checkout.telemetryEnabled = undefined; + + expect(checkout.hasAttribute("telemetry-enabled")).toBe(false); + expect(checkout.telemetryEnabled).toBe(true); + }); + }); }); describe("URL generation", () => { @@ -365,6 +398,55 @@ describe("", () => { }); describe("lifecycle", () => { + it("does not start telemetry when disabled before connection", () => { + const startSpy = vi.spyOn(mockTelemetry(), "start"); + + renderCheckout({ "telemetry-enabled": "false" }); + + expect(startSpy).not.toHaveBeenCalled(); + }); + + it("uses a separate telemetry client for each connected element", () => { + const clients = new Set(); + overrideTelemetryFactoryForTesting(() => { + const client = createTestTelemetry(); + clients.add(client); + return client; + }); + + renderCheckout(); + renderCheckout(); + + expect(clients.size).toBe(2); + }); + + it("discards buffered telemetry when disabled at runtime", () => { + const shutdownSpy = vi.spyOn(mockTelemetry(), "shutdown").mockResolvedValue(true); + const checkout = renderCheckout(); + + checkout.telemetryEnabled = false; + + expect(shutdownSpy).toHaveBeenCalledWith({ discardPending: true }); + }); + + it("flushes and shuts down telemetry when disconnected", () => { + const shutdownSpy = vi.spyOn(mockTelemetry(), "shutdown").mockResolvedValue(true); + const checkout = renderCheckout(); + + checkout.remove(); + + expect(shutdownSpy).toHaveBeenCalledWith({ keepalive: true }); + }); + + it("flushes the existing telemetry client on pagehide", () => { + const flushSpy = vi.spyOn(mockTelemetry(), "flush").mockResolvedValue(true); + renderCheckout(); + + window.dispatchEvent(new Event("pagehide")); + + expect(flushSpy).toHaveBeenCalledWith({ keepalive: true }); + }); + it("replaces the protocol message listener on reconnect and aborts it on disconnect", () => { const addEventListener = vi.spyOn(window, "addEventListener"); const checkout = renderCheckout(); diff --git a/platforms/web/src/checkout.ts b/platforms/web/src/checkout.ts index 7f53295a0..a9e4efac2 100644 --- a/platforms/web/src/checkout.ts +++ b/platforms/web/src/checkout.ts @@ -10,7 +10,9 @@ import { import stylesText from "./checkout.css?inline"; import { Logger, coerceLogLevel } from "./logger"; +import { createTelemetry, telemetryProtocolMethod, type CheckoutKitTelemetry } from "./telemetry"; import { createTemplate, html, safe } from "./utils"; +import { CK_VERSION } from "./version"; import type { CheckoutAttributes, CheckoutMethods, @@ -24,11 +26,9 @@ import type { MessageRejectedDetail, } from "./checkout.types"; -declare const CHECKOUT_KIT_PACKAGE_VERSION: string; - export const DEFAULT_POPUP_WIDTH = 600; export const DEFAULT_POPUP_HEIGHT = 600; -export const CK_VERSION: string = CHECKOUT_KIT_PACKAGE_VERSION; +export { CK_VERSION } from "./version"; /** * Trusted origin always allowed to post messages, alongside the cart URL @@ -179,7 +179,7 @@ export class ShopifyCheckout extends HTMLElement implements CheckoutAttributes, CheckoutMethods, CheckoutProperties { - static observedAttributes = ["src", "target", "appearance"] as const; + static observedAttributes = ["src", "target", "appearance", "telemetry-enabled"] as const; constructor() { super(); @@ -198,6 +198,8 @@ export class ShopifyCheckout #checkoutProtocolController: { controller: AbortController } | null = null; // Shared protocol client that decodes messages and dispatches to handlers #client!: EmbeddedCheckoutProtocol.Client; + #telemetryClient?: CheckoutKitTelemetry; + #navigationStartedAt?: number; /* ------------------------------------------------------------ * Read/write properties (reflected with attributes) @@ -262,6 +264,24 @@ export class ShopifyCheckout #logger = new Logger("", () => this.logLevel); + get telemetryEnabled(): boolean { + return this.getAttribute("telemetry-enabled")?.toLowerCase() !== "false"; + } + + set telemetryEnabled(value: boolean | undefined) { + // `#setAttribute` removes boolean `false`, which would restore the enabled default. + if (value === undefined) { + this.removeAttribute("telemetry-enabled"); + } else { + this.setAttribute("telemetry-enabled", String(value)); + } + } + + get #telemetry() { + if (!this.telemetryEnabled) return undefined; + return (this.#telemetryClient ??= createTelemetry()); + } + get target(): CheckoutTarget | string { return this.getAttribute("target") ?? "auto"; } @@ -393,6 +413,13 @@ export class ShopifyCheckout if (!src) { this.#logger.warn("src property is empty or invalid, cannot open checkout"); + this.#telemetry?.recordError({ + category: "navigation", + stage: "initialization", + code: "unknown", + retryable: false, + isRetry: false, + }); return; } @@ -402,6 +429,7 @@ export class ShopifyCheckout } let checkoutWindow: WindowProxy | null = null; + const navigationStartedAt = performance.now(); switch (target) { case "popup": { @@ -492,6 +520,7 @@ export class ShopifyCheckout } abortController.signal.addEventListener("abort", () => { + this.#navigationStartedAt = undefined; checkoutWindow?.close(); this.#checkoutWindow = null; this.#currentOpen = null; @@ -519,6 +548,18 @@ export class ShopifyCheckout this.#currentOpen = { controller: abortController }; this.#checkoutWindow = checkoutWindow; + this.#navigationStartedAt = + checkoutWindow && this.telemetryEnabled ? navigationStartedAt : undefined; + + if (!checkoutWindow) { + this.#telemetry?.recordError({ + category: "navigation", + stage: "presentation", + code: "unknown", + retryable: false, + isRetry: false, + }); + } } close(): void { @@ -527,6 +568,17 @@ export class ShopifyCheckout } } + #recordNavigationDuration(result: "success" | "failure"): void { + const startedAt = this.#navigationStartedAt; + if (startedAt === undefined) return; + this.#navigationStartedAt = undefined; + this.#telemetry?.recordNavigationDuration({ + milliseconds: performance.now() - startedAt, + result, + preloaded: false, + }); + } + override focus(): void { this.#checkoutWindow?.focus(); } @@ -687,6 +739,13 @@ export class ShopifyCheckout window.addEventListener("message", this.#handleMessage, { signal: this.#checkoutProtocolController.controller.signal, }); + window.addEventListener( + "pagehide", + () => void this.#telemetryClient?.flush({ keepalive: true }), + { + signal: this.#checkoutProtocolController.controller.signal, + }, + ); } #handleMessage = (event: MessageEvent) => { @@ -711,6 +770,10 @@ export class ShopifyCheckout error instanceof Error ? error.message : String(error) }`, ); + this.#telemetry?.recordProtocolDecodeError({ + method: "unknown", + failureType: "serialization", + }); return; } @@ -733,6 +796,10 @@ export class ShopifyCheckout `dropped ${method}: failed to decode payload`, error instanceof Error ? error.message : String(error), ); + this.#telemetry?.recordProtocolDecodeError({ + method: telemetryProtocolMethod(method), + failureType: "params", + }); }) .on(Event.ready, () => ({ ucp: { @@ -741,6 +808,9 @@ export class ShopifyCheckout }, })) .on(Event.start, ({ params: { checkout } }) => { + // Web cannot reliably observe cross-origin popup page-finish, so the + // success duration ends at `ec.start`: checkout is loaded and interactive. + this.#recordNavigationDuration("success"); this.#checkout = checkout; this.dispatchEvent(new ShopifyCheckoutStartEvent({ checkout })); }) @@ -749,16 +819,19 @@ export class ShopifyCheckout this.dispatchEvent(new ShopifyCheckoutCompleteEvent({ checkout })); }) .on(Event.error, ({ params: { error } }) => { + this.#telemetry?.recordError({ + category: "protocol", + stage: "message", + code: "unknown", + retryable: false, + isRetry: false, + }); this.#error = error; this.dispatchEvent(new ShopifyCheckoutErrorEvent({ error })); - // Per UCP spec, `unrecoverable` means no valid resource exists to act on — - // the kit closes so consumers don't have to wire dismissal in every handler. - if ( - Array.isArray(error.messages) && - error.messages.some((m) => m.severity === "unrecoverable") - ) { - this.close(); - } + // `ec.error` is terminal for the embedded session. Message severity is + // payload detail for the checkout error, not a host-side recovery signal. + this.#recordNavigationDuration("failure"); + this.close(); }) .on(Event.lineItemsChange, ({ params: { checkout } }) => { this.#checkout = checkout; @@ -833,6 +906,7 @@ export class ShopifyCheckout */ connectedCallback(): void { + this.#telemetry?.start(); this.#applyTargetClass(); this.#updateOverlayLink(); @@ -843,12 +917,15 @@ export class ShopifyCheckout this.#checkoutProtocolController?.controller.abort(); this.#checkoutProtocolController = null; this.close(); + const telemetryClient = this.#telemetryClient; + this.#telemetryClient = undefined; + if (telemetryClient) void telemetryClient.shutdown({ keepalive: true }); } attributeChangedCallback( name: (typeof ShopifyCheckout.observedAttributes)[number], - oldValue: string, - newValue: string, + oldValue: string | null, + newValue: string | null, ): void { if (oldValue === newValue) return; @@ -869,6 +946,17 @@ export class ShopifyCheckout break; } + case "telemetry-enabled": { + if (this.telemetryEnabled) { + if (this.isConnected) this.#telemetry?.start(); + } else { + this.#navigationStartedAt = undefined; + const telemetryClient = this.#telemetryClient; + this.#telemetryClient = undefined; + if (telemetryClient) void telemetryClient.shutdown({ discardPending: true }); + } + break; + } } } diff --git a/platforms/web/src/checkout.types.ts b/platforms/web/src/checkout.types.ts index c2fe1fb6c..a6caf1408 100644 --- a/platforms/web/src/checkout.types.ts +++ b/platforms/web/src/checkout.types.ts @@ -34,6 +34,7 @@ export interface CheckoutAttributes { target?: CheckoutTarget | string; appearance?: CheckoutAppearance | string; "log-level"?: LogLevel; + "telemetry-enabled"?: "true" | "false"; /** * Space/comma-separated list of extra trusted message origin patterns. Each * entry may be an exact origin (`https://example.com`), a wildcard subdomain @@ -112,6 +113,14 @@ export interface CheckoutProperties { */ logLevel?: LogLevel; + /** + * Controls anonymous diagnostic metrics sent by Checkout Kit. Defaults to `true`. + * + * This property is reflected to the `telemetry-enabled` attribute. Set it to + * `false` before opening checkout to opt out. + */ + telemetryEnabled?: boolean; + /** * Extra origins allowed to post incoming checkout-protocol messages, on top * of the always-trusted cart URL origin (from `src`) and `shop.app`. diff --git a/platforms/web/src/telemetry.test-helpers.ts b/platforms/web/src/telemetry.test-helpers.ts new file mode 100644 index 000000000..0135a44ed --- /dev/null +++ b/platforms/web/src/telemetry.test-helpers.ts @@ -0,0 +1,27 @@ +import { + createCheckoutKitTelemetryForTesting, + type CheckoutKitTelemetry, +} from "@shopify/checkout-kit-telemetry"; +import { onTestFinished } from "vitest"; + +import { overrideTelemetryFactoryForTesting } from "./telemetry"; + +// Stub the transport so an unmocked flush can never reach the +// production OTLP endpoint from a test run. +export function createTestTelemetry(): CheckoutKitTelemetry { + return createCheckoutKitTelemetryForTesting({ + sdkVersion: "test", + fetch: () => Promise.resolve({ ok: true, status: 200 }), + }); +} + +export function installTestTelemetryFactory(): void { + overrideTelemetryFactoryForTesting(createTestTelemetry); +} + +export function mockTelemetry(): CheckoutKitTelemetry { + const telemetry = createTestTelemetry(); + overrideTelemetryFactoryForTesting(() => telemetry); + onTestFinished(installTestTelemetryFactory); + return telemetry; +} diff --git a/platforms/web/src/telemetry.ts b/platforms/web/src/telemetry.ts new file mode 100644 index 000000000..c28688f15 --- /dev/null +++ b/platforms/web/src/telemetry.ts @@ -0,0 +1,26 @@ +import { + createCheckoutKitTelemetry, + toProtocolMethod, + type CheckoutKitTelemetry, + type TelemetryProtocolMethod, +} from "@shopify/checkout-kit-telemetry"; + +import { CK_VERSION } from "./version"; + +export type { CheckoutKitTelemetry } from "@shopify/checkout-kit-telemetry"; + +let telemetryFactory = () => createCheckoutKitTelemetry(CK_VERSION); + +export function createTelemetry(): CheckoutKitTelemetry { + return telemetryFactory(); +} + +export function overrideTelemetryFactoryForTesting( + factory: (() => CheckoutKitTelemetry) | undefined, +): void { + telemetryFactory = factory ?? (() => createCheckoutKitTelemetry(CK_VERSION)); +} + +export function telemetryProtocolMethod(method: string): TelemetryProtocolMethod { + return toProtocolMethod(method); +} diff --git a/platforms/web/src/version.ts b/platforms/web/src/version.ts new file mode 100644 index 000000000..fd7a3bc61 --- /dev/null +++ b/platforms/web/src/version.ts @@ -0,0 +1,3 @@ +declare const CHECKOUT_KIT_PACKAGE_VERSION: string; + +export const CK_VERSION: string = CHECKOUT_KIT_PACKAGE_VERSION; diff --git a/platforms/web/vite.config.ts b/platforms/web/vite.config.ts index b0d31d790..4b4a9c65f 100644 --- a/platforms/web/vite.config.ts +++ b/platforms/web/vite.config.ts @@ -53,6 +53,7 @@ export default defineConfig({ }, }, globals: true, + setupFiles: ['./vitest.setup.ts'], include: ['src/**/*.test.ts', 'sample/**/*.test.ts'], coverage: { provider: 'v8', diff --git a/platforms/web/vitest.setup.ts b/platforms/web/vitest.setup.ts new file mode 100644 index 000000000..6169bc129 --- /dev/null +++ b/platforms/web/vitest.setup.ts @@ -0,0 +1,5 @@ +// Default every test to a telemetry client with a stubbed transport so no +// test can post metrics to the production OTLP endpoint. +import { installTestTelemetryFactory } from "./src/telemetry.test-helpers"; + +installTestTelemetryFactory();