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();