diff --git a/README.md b/README.md index 3216552..266a6cc 100644 --- a/README.md +++ b/README.md @@ -57,8 +57,11 @@ A working VTEX storefront needs three things: a `deco-vtex` config block, an `in ```ts import { createSiteSetup } from "@decocms/start/setup"; -import { createInstrumentedFetch } from "@decocms/start/sdk/instrumentedFetch"; -import { initVtexFromBlocks, setVtexFetch } from "@decocms/apps/vtex/client"; +import { + createVtexFetch, + initVtexFromBlocks, + setVtexFetch, +} from "@decocms/apps/vtex"; import { createVtexCommerceLoaders } from "@decocms/apps/vtex/commerceLoaders"; createSiteSetup({ @@ -69,9 +72,28 @@ createSiteSetup({ getCommerceLoaders: () => createVtexCommerceLoaders(), }); -setVtexFetch(createInstrumentedFetch("vtex")); +// Plumbs spans, traceparent injection, URL redaction, and the +// `commerce_request_duration_ms` histogram into every outbound +// VTEX call. Operation names are derived from the URL via +// `vtexOperationRouter` (overridable per call via `init.operation`). +setVtexFetch(createVtexFetch()); ``` +For Shopify storefronts the equivalent factory is `createShopifyFetch()`: + +```ts +import { createShopifyFetch, setShopifyFetch } from "@decocms/apps/shopify"; + +setShopifyFetch(createShopifyFetch()); +``` + +Shopify's GraphQL operation name (`query Foo { ... }` → `Foo`) is +extracted from the document body and stamped automatically — spans +become `shopify.Foo` instead of the generic `shopify.storefront.graphql`. + +> **Heads up:** the factories require `@decocms/start@>=5.3.0-rc.0` +> for the per-call `init.operation` API used to label spans. + #### 3. Hooks in components ```tsx @@ -134,7 +156,9 @@ await sendEmail({ | Subpath | Purpose | |---------|---------| | `@decocms/apps/vtex` | Barrel index | -| `@decocms/apps/vtex/client` | `vtexFetch`, `vtexFetchWithCookies`, `intelligentSearch`, `setVtexFetch`, `initVtexFromBlocks`, `configureVtex` | +| `@decocms/apps/vtex/client` | `vtexFetch`, `vtexFetchWithCookies`, `intelligentSearch`, `setVtexFetch`, `getVtexFetch`, `initVtexFromBlocks`, `configureVtex` | +| `@decocms/apps/vtex` (barrel) | All of `client`, plus `createVtexFetch`, `vtexOperationRouter` for observability wiring | +| `@decocms/apps/shopify` (barrel) | `createShopifyFetch`, `setShopifyFetch`, `shopifyOperationRouter`, `extractGraphqlOperationName` for observability wiring | | `@decocms/apps/vtex/commerceLoaders` | `createVtexCommerceLoaders` | | `@decocms/apps/vtex/loaders/*` | Cart, user, wishlist, search, catalog, sessions, orders, autocomplete | | `@decocms/apps/vtex/actions/*` | Cart mutations, auth, profile, address, wishlist, newsletter | diff --git a/knip.json b/knip.json index 7ed8aa5..d92b45d 100644 --- a/knip.json +++ b/knip.json @@ -23,8 +23,5 @@ ], "ignoreBinaries": [ "semantic-release" - ], - "ignoreDependencies": [ - "@decocms/start" ] } diff --git a/package-lock.json b/package-lock.json index 2ba10de..8d68683 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "devDependencies": { "@biomejs/biome": "^2.4.7", - "@decocms/start": "^2.5.0", + "@decocms/start": "5.3.0-rc.0", "@semantic-release/exec": "^7.1.0", "@tanstack/react-query": "^5.90.21", "@types/react": "^19.0.0", @@ -27,7 +27,7 @@ "node": ">=18" }, "peerDependencies": { - "@decocms/start": ">=0.19.0", + "@decocms/start": ">=5.3.0-rc.0", "@tanstack/react-query": ">=5", "react": ">=18", "react-dom": ">=18" @@ -626,35 +626,38 @@ } }, "node_modules/@decocms/start": { - "version": "2.5.0", - "resolved": "https://registry.npmjs.org/@decocms/start/-/start-2.5.0.tgz", - "integrity": "sha512-vJ/H15DHnz5VOcENur00lcRFvavrhi0FMzKiCDEYAfeESViX9wlDUOjjoJii+k8I4IOp5QO4GrjYE/XGRtdbAA==", + "version": "5.3.0-rc.0", + "resolved": "https://registry.npmjs.org/@decocms/start/-/start-5.3.0-rc.0.tgz", + "integrity": "sha512-Rnhj3t/g23c7y0e/bpRkPvmRjw0GwaPFqcDP/PBvyjjHZafWtmpDVGH993I5Db9ksQ23SlddI6T8ykHrwF6qNQ==", "dev": true, "license": "MIT", "dependencies": { "@deco-cx/warp-node": "^0.3.16", + "@opentelemetry/api": "^1.9.1", + "clsx": "^2.1.1", "fast-json-patch": "^3.1.0", + "tailwind-merge": "^3.3.1", "tsx": "^4.19.0", "ws": "^8.18.0" }, "bin": { - "deco-migrate": "scripts/migrate.ts" + "deco-audit-observability": "dist/scripts/audit-observability-config.cjs", + "deco-cf-observability": "dist/scripts/migrate-to-cf-observability.cjs", + "deco-htmx-analyze": "dist/scripts/htmx-analyze.cjs", + "deco-migrate": "dist/scripts/migrate.cjs", + "deco-post-cleanup": "dist/scripts/migrate-post-cleanup.cjs" }, "peerDependencies": { - "@microlabs/otel-cf-workers": ">=1.0.0-rc.0", - "@opentelemetry/api": ">=1.9.0", "@tanstack/react-query": ">=5.0.0", "@tanstack/react-start": ">=1.0.0", "@tanstack/store": ">=0.7.0", + "next": ">=15.0.0", "react": "^19.0.0", "react-dom": "^19.0.0", "vite": ">=6.0.0 || >=7.0.0 || >=8.0.0" }, "peerDependenciesMeta": { - "@microlabs/otel-cf-workers": { - "optional": true - }, - "@opentelemetry/api": { + "next": { "optional": true } } @@ -1466,6 +1469,16 @@ "node": ">=20.0" } }, + "node_modules/@opentelemetry/api": { + "version": "1.9.1", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz", + "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/@oxc-project/runtime": { "version": "0.115.0", "resolved": "https://registry.npmjs.org/@oxc-project/runtime/-/runtime-0.115.0.tgz", @@ -4325,6 +4338,16 @@ "url": "https://github.com/chalk/strip-ansi?sponsor=1" } }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -10520,6 +10543,17 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "dev": true, + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, "node_modules/temp-dir": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/temp-dir/-/temp-dir-3.0.0.tgz", diff --git a/package.json b/package.json index f28ee0e..fe02e4e 100644 --- a/package.json +++ b/package.json @@ -109,14 +109,14 @@ "access": "public" }, "peerDependencies": { - "@decocms/start": ">=0.19.0", + "@decocms/start": ">=5.3.0-rc.0", "@tanstack/react-query": ">=5", "react": ">=18", "react-dom": ">=18" }, "devDependencies": { "@biomejs/biome": "^2.4.7", - "@decocms/start": "^2.5.0", + "@decocms/start": "5.3.0-rc.0", "@semantic-release/exec": "^7.1.0", "@tanstack/react-query": "^5.90.21", "@types/react": "^19.0.0", diff --git a/shopify/index.ts b/shopify/index.ts index ad884df..3d10e3d 100644 --- a/shopify/index.ts +++ b/shopify/index.ts @@ -34,4 +34,10 @@ export { default as userLoader } from "./loaders/user"; export { getCartCookie, setCartCookie } from "./utils/cart"; // Cookie utils export { getCookies, setCookie } from "./utils/cookies"; +export { extractGraphqlOperationName } from "./utils/graphqlOperationName"; +export { + type CreateShopifyFetchOptions, + createShopifyFetch, +} from "./utils/instrumentedFetch"; +export { shopifyOperationRouter } from "./utils/operationRouter"; export { getUserCookie, setUserCookie } from "./utils/user"; diff --git a/shopify/utils/__tests__/graphqlOperationName.test.ts b/shopify/utils/__tests__/graphqlOperationName.test.ts new file mode 100644 index 0000000..5eda77d --- /dev/null +++ b/shopify/utils/__tests__/graphqlOperationName.test.ts @@ -0,0 +1,80 @@ +import { describe, expect, it } from "vitest"; +import { extractGraphqlOperationName } from "../graphqlOperationName"; + +describe("extractGraphqlOperationName", () => { + it("returns the explicit name when provided, regardless of body content", () => { + expect(extractGraphqlOperationName("query Whatever { x }", "ForcedName")).toBe("ForcedName"); + expect(extractGraphqlOperationName("", "Override")).toBe("Override"); + }); + + it("extracts a single named query", () => { + expect( + extractGraphqlOperationName("query ProductBySlug($slug: String!) { product { id } }"), + ).toBe("ProductBySlug"); + }); + + it("extracts a single named mutation", () => { + expect( + extractGraphqlOperationName("mutation CartLinesAdd($cartId: ID!) { cartLinesAdd { } }"), + ).toBe("CartLinesAdd"); + }); + + it("extracts a single named subscription", () => { + expect(extractGraphqlOperationName("subscription OrderEvents { orderUpdated { id } }")).toBe( + "OrderEvents", + ); + }); + + it("returns undefined for anonymous operations", () => { + expect(extractGraphqlOperationName("{ product { id } }")).toBeUndefined(); + expect(extractGraphqlOperationName("query { product { id } }")).toBeUndefined(); + }); + + it("returns undefined when document has more than one named operation (caller must disambiguate)", () => { + const multi = ` + query OpA { a } + query OpB { b } + `; + expect(extractGraphqlOperationName(multi)).toBeUndefined(); + }); + + it("ignores the words query/mutation/subscription inside string literals", () => { + const docWithStringy = `query RealName { thing(arg: "this query mutation subscription is a string") }`; + expect(extractGraphqlOperationName(docWithStringy)).toBe("RealName"); + }); + + it("ignores the words inside block strings (triple-quoted)", () => { + const doc = ` + """ + This block string mentions query Inner and mutation Inner2. + """ + query OuterReal { x } + `; + expect(extractGraphqlOperationName(doc)).toBe("OuterReal"); + }); + + it("ignores the words inside # comments", () => { + const doc = ` + # query CommentedOut { x } + query Active { y } + `; + expect(extractGraphqlOperationName(doc)).toBe("Active"); + }); + + it("returns undefined on an empty / nullish body", () => { + expect(extractGraphqlOperationName("")).toBeUndefined(); + expect(extractGraphqlOperationName(" \n\t ")).toBeUndefined(); + }); + + it("handles a real-world Shopify storefront query shape", () => { + const doc = ` + query ProductDetails($handle: String!, $country: CountryCode!) @inContext(country: $country) { + product(handle: $handle) { + id + title + } + } + `; + expect(extractGraphqlOperationName(doc)).toBe("ProductDetails"); + }); +}); diff --git a/shopify/utils/__tests__/instrumentedFetch.test.ts b/shopify/utils/__tests__/instrumentedFetch.test.ts new file mode 100644 index 0000000..133d617 --- /dev/null +++ b/shopify/utils/__tests__/instrumentedFetch.test.ts @@ -0,0 +1,83 @@ +/** + * Smoke tests for the pre-wired Shopify fetch factory. Same wiring + * assertions as `vtex/utils/__tests__/instrumentedFetch.test.ts`. + */ + +import { configureMeter, type MeterAdapter } from "@decocms/start/sdk/observability"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createShopifyFetch } from "../instrumentedFetch"; + +type Labels = Record; + +function captureHistogram(): { + calls: { name: string; value: number; attrs: Labels }[]; + meter: MeterAdapter; +} { + const calls: { name: string; value: number; attrs: Labels }[] = []; + const meter: MeterAdapter = { + counterInc: vi.fn(), + gaugeSet: vi.fn(), + histogramRecord: (name, value, attrs) => { + calls.push({ name, value, attrs: attrs ?? {} }); + }, + }; + return { calls, meter }; +} + +describe("createShopifyFetch", () => { + afterEach(() => { + vi.restoreAllMocks(); + configureMeter({ + counterInc: () => {}, + gaugeSet: () => {}, + histogramRecord: () => {}, + }); + }); + + it("emits commerce_request_duration_ms with provider=shopify on success", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async () => new Response("{}", { status: 200 })); + const fetchFn = createShopifyFetch({ baseFetch: baseFetch as typeof fetch }); + + await fetchFn("https://store.myshopify.com/api/2025-04/graphql.json", { method: "POST" }); + + expect(calls).toHaveLength(1); + expect(calls[0].attrs).toMatchObject({ + provider: "shopify", + operation: "storefront.graphql", + status_code: "200", + }); + }); + + it("honors init.operation (used by the GraphQL client to stamp )", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async () => new Response("{}", { status: 200 })); + const fetchFn = createShopifyFetch({ baseFetch: baseFetch as typeof fetch }); + + await fetchFn("https://store.myshopify.com/api/2025-04/graphql.json", { + method: "POST", + operation: "ProductBySlug", + }); + + expect(calls[0].attrs.operation).toBe("ProductBySlug"); + }); + + it("skips histogram emission when disableHistogram is true", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async () => new Response("{}", { status: 200 })); + const fetchFn = createShopifyFetch({ + baseFetch: baseFetch as typeof fetch, + disableHistogram: true, + }); + + await fetchFn("https://store.myshopify.com/api/2025-04/graphql.json", { method: "POST" }); + + expect(calls).toHaveLength(0); + }); +}); diff --git a/shopify/utils/__tests__/operationRouter.test.ts b/shopify/utils/__tests__/operationRouter.test.ts new file mode 100644 index 0000000..df2e2d8 --- /dev/null +++ b/shopify/utils/__tests__/operationRouter.test.ts @@ -0,0 +1,56 @@ +import { describe, expect, it } from "vitest"; +import { shopifyOperationRouter } from "../operationRouter"; + +describe("shopifyOperationRouter", () => { + const store = "https://acme.myshopify.com"; + + it("recognizes the storefront GraphQL endpoint", () => { + expect(shopifyOperationRouter(`${store}/api/2025-04/graphql.json`, "POST")).toBe( + "storefront.graphql", + ); + }); + + it("recognizes the admin GraphQL endpoint", () => { + expect(shopifyOperationRouter(`${store}/admin/api/2025-04/graphql.json`, "POST")).toBe( + "admin.graphql", + ); + }); + + it("maps admin REST product / order / customer / inventory endpoints", () => { + expect(shopifyOperationRouter(`${store}/admin/api/2025-04/products.json`, "GET")).toBe( + "admin.products", + ); + expect(shopifyOperationRouter(`${store}/admin/api/2025-04/orders/123.json`, "GET")).toBe( + "admin.orders", + ); + expect(shopifyOperationRouter(`${store}/admin/api/2025-04/customers.json`, "GET")).toBe( + "admin.customers", + ); + expect(shopifyOperationRouter(`${store}/admin/api/2025-04/inventory_levels.json`, "GET")).toBe( + "admin.inventory", + ); + }); + + it("maps storefront checkout + cart endpoints", () => { + expect(shopifyOperationRouter(`${store}/api/2025-04/checkouts/abc.json`, "POST")).toBe( + "storefront.checkout", + ); + expect(shopifyOperationRouter(`${store}/cart.js`, "GET")).toBe("storefront.cart"); + expect(shopifyOperationRouter(`${store}/cart/add.js`, "POST")).toBe("storefront.cart"); + }); + + it("returns undefined for unrecognized paths", () => { + expect(shopifyOperationRouter(`${store}/random/path`, "GET")).toBeUndefined(); + }); + + it("does not throw on unparseable URLs", () => { + expect(shopifyOperationRouter("not-a-url", "GET")).toBeUndefined(); + expect(shopifyOperationRouter("/api/2025-04/graphql.json", "POST")).toBe("storefront.graphql"); + }); + + it("is case-insensitive on method (no behavioral impact today; future-proofing)", () => { + expect(shopifyOperationRouter(`${store}/api/2025-04/graphql.json`, "post")).toBe( + "storefront.graphql", + ); + }); +}); diff --git a/shopify/utils/graphql.ts b/shopify/utils/graphql.ts index d124fd4..a0c9d63 100644 --- a/shopify/utils/graphql.ts +++ b/shopify/utils/graphql.ts @@ -1,3 +1,6 @@ +import type { InstrumentedFetchInit } from "@decocms/start/sdk/instrumentedFetch"; +import { extractGraphqlOperationName } from "./graphqlOperationName"; + export function gql(strings: TemplateStringsArray, ...values: unknown[]): string { return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), ""); } @@ -29,14 +32,22 @@ export function createGraphqlClient( ): Promise { const query = typeof queryOrDef === "string" ? queryOrDef : buildQuery(queryOrDef); - const response = await _fetch(endpoint, { + // Stamp the GraphQL operation as init.operation so the framework's + // span name becomes `shopify.` instead of the + // generic `shopify.storefront.graphql` from the URL router. The + // extra field is silently dropped by plain `fetch` and read by + // any `InstrumentedFetch` configured via `setShopifyFetch`. + const operation = extractGraphqlOperationName(query); + const init: InstrumentedFetchInit = { method: "POST", headers: { "Content-Type": "application/json", ...headers, }, body: JSON.stringify({ query, variables }), - }); + ...(operation ? { operation } : {}), + }; + const response = await _fetch(endpoint, init); if (!response.ok) { throw new Error(`Shopify GraphQL error: ${response.status} ${response.statusText}`); diff --git a/shopify/utils/graphqlOperationName.ts b/shopify/utils/graphqlOperationName.ts new file mode 100644 index 0000000..40f6324 --- /dev/null +++ b/shopify/utils/graphqlOperationName.ts @@ -0,0 +1,67 @@ +/** + * Extract a semantic operation name from a GraphQL document. + * + * Used at the Shopify GraphQL client layer to stamp `init.operation` + * on the outbound fetch. The framework then suffixes the integration + * name (`shopify.`) onto the span and uses the same string + * as the `fetch.operation` attribute + histogram label. + * + * Resolution order: + * + * 1. An explicit `operationName` argument (e.g. when the client + * received one alongside a multi-operation document) wins. + * 2. If the document has exactly one named operation, that name + * is used. + * 3. If the document has zero or many anonymous operations, we + * return `undefined` so the caller can fall back (typically to + * the URL-derived `storefront.graphql` / `admin.graphql`). + * + * The parser is deliberately a small regex pass, not a full GraphQL + * tokenizer: + * + * - GraphQL operation definitions live at the top level of the + * document, never nested inside other operations, fragments, or + * selection sets, so positional context isn't required to find + * them — only to not match the literal words `query` / + * `mutation` / `subscription` inside string values. + * - We strip block strings (`""" … """`), string literals + * (`"…"`), and `# …` comments before matching, which is enough + * to make false-positive matches inside comments / docs vanish. + * + * If a Shopify operation is ever sufficiently mis-named to break + * this (unlikely, since the storefront SDK names them deliberately), + * the caller can always set `init.operation` explicitly. + */ + +const OPERATION_RE = /\b(?:query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)/g; + +const stripCommentsAndStrings = (doc: string): string => + doc + .replace(/"""[\s\S]*?"""/g, '""') + .replace(/"(?:\\.|[^"\\])*"/g, '""') + .replace(/#[^\n]*/g, ""); + +export function extractGraphqlOperationName( + document: string, + explicit?: string, +): string | undefined { + if (explicit) return explicit; + if (!document) return undefined; + + const stripped = stripCommentsAndStrings(document); + const names: string[] = []; + + OPERATION_RE.lastIndex = 0; + for ( + let match = OPERATION_RE.exec(stripped); + match !== null; + match = OPERATION_RE.exec(stripped) + ) { + const [, name] = match; + if (name) names.push(name); + if (names.length > 1) break; + } + + if (names.length === 1) return names[0]; + return undefined; +} diff --git a/shopify/utils/instrumentedFetch.ts b/shopify/utils/instrumentedFetch.ts new file mode 100644 index 0000000..8456690 --- /dev/null +++ b/shopify/utils/instrumentedFetch.ts @@ -0,0 +1,57 @@ +/** + * Pre-wired instrumented fetch factory for Shopify. + * + * Mirrors `vtex/utils/instrumentedFetch.ts`. Bundles: + * + * 1. `createInstrumentedFetch` from `@decocms/start` (spans, + * traceparent, URL redaction). + * 2. `shopifyOperationRouter` as the URL fallback for non-GraphQL + * and unnamed-GraphQL calls. + * 3. An `onComplete` that records `commerce_request_duration_ms` + * with `provider: "shopify"`. + * + * Sites do: + * + * ```ts + * import { setShopifyFetch, createShopifyFetch } from "@decocms/apps/shopify"; + * setShopifyFetch(createShopifyFetch()); + * ``` + * + * Per-call operation names come from `extractGraphqlOperationName` + * (wired in `./graphql.ts`); the URL router fires only when the + * extractor returns `undefined`. + */ + +import { + createInstrumentedFetch, + type InstrumentedFetch, +} from "@decocms/start/sdk/instrumentedFetch"; +import { getMeter } from "@decocms/start/sdk/observability"; +import { shopifyOperationRouter } from "./operationRouter"; + +const HISTOGRAM_NAME = "commerce_request_duration_ms"; + +export interface CreateShopifyFetchOptions { + baseFetch?: typeof fetch; + disableHistogram?: boolean; +} + +export function createShopifyFetch(options: CreateShopifyFetchOptions = {}): InstrumentedFetch { + const { baseFetch, disableHistogram = false } = options; + return createInstrumentedFetch({ + name: "shopify", + baseFetch, + resolveOperation: shopifyOperationRouter, + onComplete: disableHistogram + ? undefined + : ({ operation, status, durationMs, cached }) => { + const meter = getMeter(); + meter?.histogramRecord?.(HISTOGRAM_NAME, durationMs, { + provider: "shopify", + operation, + status_code: String(status), + cached: cached ? "true" : "false", + }); + }, + }); +} diff --git a/shopify/utils/operationRouter.ts b/shopify/utils/operationRouter.ts new file mode 100644 index 0000000..3366da5 --- /dev/null +++ b/shopify/utils/operationRouter.ts @@ -0,0 +1,70 @@ +/** + * URL-derived operation name router for Shopify API calls. + * + * Plugged into `@decocms/start`'s `createInstrumentedFetch` via the + * `resolveOperation(url, method)` option. Mirrors the shape of the + * VTEX router in `../../vtex/utils/operationRouter.ts`. + * + * Shopify's API surface from this repo is overwhelmingly GraphQL — + * a single endpoint per environment (storefront vs admin). That means + * the URL alone can only tell us *which GraphQL surface* a call is + * hitting, not what the call actually does. The semantic operation + * name lives in the GraphQL document itself (`query Foo { ... }`), + * and is extracted by `extractGraphqlOperationName` (see + * `./graphqlOperationName.ts`) at the client layer and stamped as + * `init.operation`, which always wins over this router. + * + * So this router exists for: + * + * - non-GraphQL Shopify REST endpoints we may add later + * (cart API, customer accounts, billing, etc.); + * - giving the GraphQL endpoints a *fallback* operation when the + * extractor can't parse a name (anonymous queries, missing body). + */ + +type OperationResolver = string | ((match: RegExpMatchArray, method: string) => string); + +interface Matcher { + pattern: RegExp; + operation: OperationResolver; +} + +const m = (pattern: RegExp, operation: OperationResolver): Matcher => ({ pattern, operation }); + +const MATCHERS: ReadonlyArray = [ + m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/graphql\.json/, "admin.graphql"), + m(/^\/api\/[0-9]{4}-[0-9]{2}\/graphql\.json/, "storefront.graphql"), + + m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/products/, "admin.products"), + m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/orders/, "admin.orders"), + m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/customers/, "admin.customers"), + m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/inventory/, "admin.inventory"), + + m(/^\/api\/[0-9]{4}-[0-9]{2}\/checkouts/, "storefront.checkout"), + m(/^\/cart(?:\/|\.js|$)/, "storefront.cart"), +]; + +/** + * Resolve an operation name for a Shopify URL. Returns `undefined` + * if no matcher fires, which causes the framework to fall back to + * `shopify.fetch`. + */ +export function shopifyOperationRouter(url: string, method: string): string | undefined { + let pathname: string; + try { + pathname = new URL(url).pathname; + } catch { + const qs = url.indexOf("?"); + const hash = url.indexOf("#"); + const end = [qs, hash].filter((i) => i >= 0).sort((a, b) => a - b)[0]; + pathname = end === undefined ? url : url.slice(0, end); + } + + const upperMethod = method.toUpperCase(); + for (const { pattern, operation } of MATCHERS) { + const match = pathname.match(pattern); + if (!match) continue; + return typeof operation === "function" ? operation(match, upperMethod) : operation; + } + return undefined; +} diff --git a/vtex/actions/analytics/sendEvent.ts b/vtex/actions/analytics/sendEvent.ts index 20d7370..9ac8405 100644 --- a/vtex/actions/analytics/sendEvent.ts +++ b/vtex/actions/analytics/sendEvent.ts @@ -8,7 +8,7 @@ * @see https://developers.vtex.com/docs/api-reference/intelligent-search-api#post-/event-api/v1/-account-/event */ -import { getVtexConfig } from "../../client"; +import { getVtexConfig, getVtexFetch } from "../../client"; import { ANONYMOUS_COOKIE, SESSION_COOKIE } from "../../utils/intelligentSearch"; export type Props = @@ -68,7 +68,7 @@ const action = async (props: Props, req: Request): Promise => { } const url = `https://sp.vtex.com/event-api/v1/${account}/event`; - await fetch(url, { + await getVtexFetch()(url, { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ @@ -77,6 +77,7 @@ const action = async (props: Props, req: Request): Promise => { anonymous, agent: req.headers.get("user-agent") || "deco-sites/apps", }), + operation: "analytics.event", }); return null; diff --git a/vtex/actions/masterData.ts b/vtex/actions/masterData.ts index 5011977..7789a1d 100644 --- a/vtex/actions/masterData.ts +++ b/vtex/actions/masterData.ts @@ -2,7 +2,7 @@ * VTEX MasterData v2 API actions. * Generic CRUD operations on data entities. */ -import { getVtexConfig, vtexFetch, vtexFetchResponse } from "../client"; +import { getVtexConfig, getVtexFetch, vtexFetch, vtexFetchResponse } from "../client"; function removeEmptyFields(obj: Record): Record { return Object.fromEntries( @@ -153,10 +153,11 @@ export async function uploadAttachment(opts: UploadAttachmentOpts): Promise<{ ok headers["X-VTEX-API-AppToken"] = config.appToken; } - const response = await fetch(url, { + const response = await getVtexFetch()(url, { method: "POST", headers, body: formData, + operation: "masterdata.attachment.upload", }); if (!response.ok) { diff --git a/vtex/actions/misc.ts b/vtex/actions/misc.ts index 0582cea..dd1549d 100644 --- a/vtex/actions/misc.ts +++ b/vtex/actions/misc.ts @@ -7,7 +7,7 @@ * - vtex/actions/payment/deletePaymentToken.ts * @see https://developers.vtex.com/docs/api-reference */ -import { getVtexConfig, vtexFetch } from "../client"; +import { getVtexConfig, getVtexFetch, vtexFetch } from "../client"; import { buildAuthCookieHeader, VTEX_AUTH_COOKIE } from "../utils/vtexId"; // --------------------------------------------------------------------------- @@ -127,9 +127,10 @@ export async function notifyMe(props: NotifyMeProps): Promise { form.append("notifymeClientEmail", email); form.append("notifymeIdSku", skuId); - await fetch(`https://${account}.vtexcommercestable.com.br/no-cache/AviseMe.aspx`, { + await getVtexFetch()(`https://${account}.vtexcommercestable.com.br/no-cache/AviseMe.aspx`, { method: "POST", body: form, + operation: "notifyme", }); } @@ -148,7 +149,7 @@ export async function sendEvent( ): Promise { const { account } = getVtexConfig(); - await fetch(`https://sp.vtex.com/event-api/v1/${account}/event`, { + await getVtexFetch()(`https://sp.vtex.com/event-api/v1/${account}/event`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ @@ -156,6 +157,7 @@ export async function sendEvent( ...isCookies, agent: userAgent || "deco-sites/apps", }), + operation: "analytics.event", }); } diff --git a/vtex/actions/newsletter.ts b/vtex/actions/newsletter.ts index 9a20ac1..5696010 100644 --- a/vtex/actions/newsletter.ts +++ b/vtex/actions/newsletter.ts @@ -5,7 +5,7 @@ * - vtex/actions/newsletter/updateNewsletterOptIn.ts * @see https://developers.vtex.com/docs/guides/newsletter */ -import { getVtexConfig, vtexFetch } from "../client"; +import { getVtexConfig, getVtexFetch, vtexFetch } from "../client"; import { buildAuthCookieHeader } from "../utils/vtexId"; // --------------------------------------------------------------------------- @@ -83,9 +83,10 @@ export async function subscribe(props: SubscribeProps): Promise { form.append("newsInternalPart", part); form.append("newsInternalCampaign", campaing); - await fetch(`https://${account}.vtexcommercestable.com.br/no-cache/Newsletter.aspx`, { + await getVtexFetch()(`https://${account}.vtexcommercestable.com.br/no-cache/Newsletter.aspx`, { method: "POST", body: form, + operation: "newsletter.subscribe", }); } diff --git a/vtex/actions/profile.ts b/vtex/actions/profile.ts index e16294a..b38f5a2 100644 --- a/vtex/actions/profile.ts +++ b/vtex/actions/profile.ts @@ -4,7 +4,7 @@ * - vtex/actions/profile/updateProfile.ts * @see https://developers.vtex.com/docs/guides/profile-system */ -import { getVtexConfig, vtexFetch } from "../client"; +import { getVtexConfig, getVtexFetch, vtexFetch } from "../client"; import { buildAuthCookieHeader } from "../utils/vtexId"; // --------------------------------------------------------------------------- @@ -166,10 +166,11 @@ export async function updateProfileFromRequest( corporateDocument tradeName stateRegistration } }`; - const res = await fetch(`https://${account}.myvtex.com/_v/private/graphql/v1`, { + const res = await getVtexFetch()(`https://${account}.myvtex.com/_v/private/graphql/v1`, { method: "POST", body: JSON.stringify({ query: QUERY, variables: { profile } }), headers: { "Content-Type": "application/json", cookie }, + operation: "io.graphql.UpdateProfile", }); return res.json(); } diff --git a/vtex/client.ts b/vtex/client.ts index acec876..0c2e9b3 100644 --- a/vtex/client.ts +++ b/vtex/client.ts @@ -3,6 +3,10 @@ * Uses VTEX's public REST APIs (Intelligent Search + Catalog + Checkout). */ +import type { + InstrumentedFetch, + InstrumentedFetchInit, +} from "@decocms/start/sdk/instrumentedFetch"; import { RequestContext } from "@decocms/start/sdk/requestContext"; import { sanitizeOutboundCookieHeader, warnDroppedCookies } from "./utils/cookieSanitizer"; import { type FetchCacheOptions, fetchWithCache } from "./utils/fetchCache"; @@ -96,7 +100,7 @@ export interface VtexConfig { } let _config: VtexConfig | null = null; -let _fetch: typeof fetch = globalThis.fetch; +let _fetch: typeof fetch | InstrumentedFetch = globalThis.fetch; export function configureVtex(config: VtexConfig) { _config = config; @@ -105,19 +109,40 @@ export function configureVtex(config: VtexConfig) { /** * Override the fetch function used by all VTEX client calls. - * Use this to plug in instrumented fetch for logging/tracing. + * Pass an `InstrumentedFetch` to get spans, traceparent injection, + * URL redaction, and the `commerce_request_duration_ms` histogram — + * use the pre-wired `createVtexFetch()` factory: * - * @example * ```ts - * import { createInstrumentedFetch } from "@decocms/start/sdk/instrumentedFetch"; - * import { setVtexFetch } from "@decocms/apps/vtex"; - * setVtexFetch(createInstrumentedFetch("vtex")); + * import { setVtexFetch, createVtexFetch } from "@decocms/apps/vtex"; + * setVtexFetch(createVtexFetch()); * ``` + * + * Accepts a plain `typeof fetch` too; in that mode VTEX calls are + * uninstrumented (useful for tests + sites that haven't onboarded + * the observability stack yet). */ -export function setVtexFetch(fetchFn: typeof fetch) { +export function setVtexFetch(fetchFn: typeof fetch | InstrumentedFetch) { _fetch = fetchFn; } +/** + * Read-only accessor for the configured VTEX fetch. Used by ad-hoc + * callsites that don't fit the `vtexFetch*` helpers (FormData + * uploads, the storefront proxies, .aspx endpoints) but still want + * to participate in the instrumentation set up via `setVtexFetch`. + * + * Callers can stamp a per-call operation through the init: + * + * ```ts + * const fetch = getVtexFetch(); + * await fetch(url, { method: "POST", operation: "notifyme" }); + * ``` + */ +export function getVtexFetch(): InstrumentedFetch { + return _fetch as InstrumentedFetch; +} + export function getVtexConfig(): VtexConfig { if (!_config) throw new Error("VTEX not configured. Call configureVtex() first."); return _config; @@ -198,7 +223,10 @@ function hasCookieHeader(headers: HeadersInit | undefined): boolean { return Object.keys(headers).some((k) => k.toLowerCase() === "cookie"); } -export async function vtexFetchResponse(path: string, init?: RequestInit): Promise { +export async function vtexFetchResponse( + path: string, + init?: InstrumentedFetchInit, +): Promise { const raw = path.startsWith("http") ? path : `${baseUrl()}${path}`; const url = sanitizeUrl(raw); @@ -225,7 +253,7 @@ export async function vtexFetchResponse(path: string, init?: RequestInit): Promi return response; } -export async function vtexFetch(path: string, init?: RequestInit): Promise { +export async function vtexFetch(path: string, init?: InstrumentedFetchInit): Promise { const response = await vtexFetchResponse(path, init); return response.json(); } @@ -242,7 +270,7 @@ export interface VtexCachedFetchOptions { */ export async function vtexCachedFetch( path: string, - init?: RequestInit, + init?: InstrumentedFetchInit, cacheOpts?: VtexCachedFetchOptions, ): Promise { const method = (init?.method ?? "GET").toUpperCase(); @@ -276,7 +304,10 @@ export async function vtexCachedFetch( * * This mirrors deco-cx/deco's `proxySetCookie(response.headers, ctx.response.headers)`. */ -export async function vtexFetchWithCookies(path: string, init?: RequestInit): Promise { +export async function vtexFetchWithCookies( + path: string, + init?: InstrumentedFetchInit, +): Promise { // Auto-inject request cookies from RequestContext. // // We sanitize the forwarded Cookie header before sending it to VTEX: diff --git a/vtex/index.ts b/vtex/index.ts index 28d6576..781f767 100644 --- a/vtex/index.ts +++ b/vtex/index.ts @@ -13,3 +13,5 @@ */ export * from "./client"; export { configure, type VtexState } from "./mod"; +export { type CreateVtexFetchOptions, createVtexFetch } from "./utils/instrumentedFetch"; +export { vtexOperationRouter } from "./utils/operationRouter"; diff --git a/vtex/utils/__tests__/instrumentedFetch.test.ts b/vtex/utils/__tests__/instrumentedFetch.test.ts new file mode 100644 index 0000000..78ede28 --- /dev/null +++ b/vtex/utils/__tests__/instrumentedFetch.test.ts @@ -0,0 +1,157 @@ +/** + * Smoke tests for the pre-wired VTEX fetch factory. + * + * The deep coverage of `createInstrumentedFetch` lives in @decocms/start; + * here we only verify the apps-start wiring decisions: + * + * - The URL router is plumbed through so unannotated callsites get + * semantic span operations + histogram labels. + * - The `commerce_request_duration_ms` histogram is recorded with the + * right labels on every call. + * - `disableHistogram: true` opts out cleanly. + * - A caller's explicit `init.operation` wins over the URL router + * (delegating to the framework, but worth asserting at this seam). + */ + +import { configureMeter, type MeterAdapter } from "@decocms/start/sdk/observability"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { createVtexFetch } from "../instrumentedFetch"; + +type Labels = Record; +type HistogramCall = { + name: string; + value: number; + attrs: Labels; +}; + +function captureHistogram(): { calls: HistogramCall[]; meter: MeterAdapter } { + const calls: HistogramCall[] = []; + const meter: MeterAdapter = { + counterInc: vi.fn(), + gaugeSet: vi.fn(), + histogramRecord: (name, value, attrs) => { + calls.push({ name, value, attrs: attrs ?? {} }); + }, + }; + return { calls, meter }; +} + +function mockOkResponse(status = 200): Response { + return new Response(JSON.stringify({}), { status }); +} + +describe("createVtexFetch", () => { + afterEach(() => { + vi.restoreAllMocks(); + configureMeter({ + counterInc: () => {}, + gaugeSet: () => {}, + histogramRecord: () => {}, + }); + }); + + it("records commerce_request_duration_ms with provider/operation/status labels on success", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async () => mockOkResponse(200)); + const fetchFn = createVtexFetch({ baseFetch: baseFetch as typeof fetch }); + + await fetchFn("https://store.vtexcommercestable.com.br/api/sessions"); + + expect(calls).toHaveLength(1); + expect(calls[0].name).toBe("commerce_request_duration_ms"); + expect(calls[0].attrs).toMatchObject({ + provider: "vtex", + operation: "sessions.get", + status_code: "200", + cached: "false", + }); + expect(calls[0].value).toBeGreaterThanOrEqual(0); + }); + + it("uses the URL router for unannotated calls", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async () => mockOkResponse(200)); + const fetchFn = createVtexFetch({ baseFetch: baseFetch as typeof fetch }); + + await fetchFn( + "https://store.vtexcommercestable.com.br/api/io/_v/api/intelligent-search/product_search/foo", + ); + + expect(calls[0].attrs.operation).toBe("intelligent-search.product_search"); + }); + + it("honors init.operation over the URL router", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async () => mockOkResponse(200)); + const fetchFn = createVtexFetch({ baseFetch: baseFetch as typeof fetch }); + + await fetchFn("https://store.vtexcommercestable.com.br/api/sessions", { + operation: "explicit.custom_op", + }); + + expect(calls[0].attrs.operation).toBe("explicit.custom_op"); + }); + + it("records cached=true when the response carries x-cache: HIT", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn( + async () => new Response("{}", { status: 200, headers: { "x-cache": "HIT" } }), + ); + const fetchFn = createVtexFetch({ baseFetch: baseFetch as typeof fetch }); + + await fetchFn("https://store.vtexcommercestable.com.br/api/sessions"); + + expect(calls[0].attrs.cached).toBe("true"); + }); + + it("emits status_code reflecting the actual response status", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async () => mockOkResponse(503)); + const fetchFn = createVtexFetch({ baseFetch: baseFetch as typeof fetch }); + + await fetchFn("https://store.vtexcommercestable.com.br/api/sessions"); + + expect(calls[0].attrs.status_code).toBe("503"); + }); + + it("skips histogram emission when disableHistogram is true", async () => { + const { calls, meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async () => mockOkResponse(200)); + const fetchFn = createVtexFetch({ + baseFetch: baseFetch as typeof fetch, + disableHistogram: true, + }); + + await fetchFn("https://store.vtexcommercestable.com.br/api/sessions"); + + expect(calls).toHaveLength(0); + }); + + it("does not surface the operation field to the underlying fetch", async () => { + const { meter } = captureHistogram(); + configureMeter(meter); + + const baseFetch = vi.fn(async (_input: unknown, _init?: RequestInit) => mockOkResponse(200)); + const fetchFn = createVtexFetch({ baseFetch: baseFetch as unknown as typeof fetch }); + + await fetchFn("https://store.vtexcommercestable.com.br/api/sessions", { + operation: "explicit.op", + }); + + expect(baseFetch).toHaveBeenCalledOnce(); + const init = baseFetch.mock.calls[0]?.[1] as Record | undefined; + expect(init?.operation).toBeUndefined(); + }); +}); diff --git a/vtex/utils/__tests__/operationRouter.test.ts b/vtex/utils/__tests__/operationRouter.test.ts new file mode 100644 index 0000000..d89c8c7 --- /dev/null +++ b/vtex/utils/__tests__/operationRouter.test.ts @@ -0,0 +1,227 @@ +import { describe, expect, it } from "vitest"; +import { vtexOperationRouter } from "../operationRouter"; + +describe("vtexOperationRouter", () => { + const acct = "https://store.vtexcommercestable.com.br"; + + describe("Intelligent Search", () => { + it("captures the IS endpoint as the operation suffix", () => { + expect( + vtexOperationRouter( + `${acct}/api/io/_v/api/intelligent-search/product_search/electronics`, + "GET", + ), + ).toBe("intelligent-search.product_search"); + expect( + vtexOperationRouter(`${acct}/api/io/_v/api/intelligent-search/top_searches`, "GET"), + ).toBe("intelligent-search.top_searches"); + expect(vtexOperationRouter(`${acct}/api/io/_v/api/intelligent-search/facets`, "GET")).toBe( + "intelligent-search.facets", + ); + expect( + vtexOperationRouter(`${acct}/api/io/_v/api/intelligent-search/search_suggestions`, "GET"), + ).toBe("intelligent-search.search_suggestions"); + }); + + it("strips query string before matching", () => { + expect( + vtexOperationRouter( + `${acct}/api/io/_v/api/intelligent-search/product_search?query=foo&sort=price`, + "GET", + ), + ).toBe("intelligent-search.product_search"); + }); + }); + + describe("Checkout / orderForm", () => { + it("differentiates create vs get on orderForm", () => { + expect(vtexOperationRouter(`${acct}/api/checkout/pub/orderForm`, "POST")).toBe( + "checkout.orderform.create", + ); + expect(vtexOperationRouter(`${acct}/api/checkout/pub/orderForm`, "GET")).toBe( + "checkout.orderform.get", + ); + }); + + it("differentiates items add/update/remove by HTTP method", () => { + const url = `${acct}/api/checkout/pub/orderForm/abc123/items`; + expect(vtexOperationRouter(url, "POST")).toBe("checkout.orderform.items.add"); + expect(vtexOperationRouter(url, "PATCH")).toBe("checkout.orderform.items.update"); + expect(vtexOperationRouter(url, "PUT")).toBe("checkout.orderform.items.update"); + expect(vtexOperationRouter(url, "DELETE")).toBe("checkout.orderform.items.remove"); + }); + + it("recognizes the /items/update legacy mass-update endpoint", () => { + expect( + vtexOperationRouter(`${acct}/api/checkout/pub/orderForm/abc123/items/update`, "POST"), + ).toBe("checkout.orderform.items.update"); + }); + + it("handles coupons, profile, shippingData, paymentData", () => { + const id = "abc123"; + expect(vtexOperationRouter(`${acct}/api/checkout/pub/orderForm/${id}/coupons`, "POST")).toBe( + "checkout.orderform.coupons", + ); + expect(vtexOperationRouter(`${acct}/api/checkout/pub/orderForm/${id}/profile`, "POST")).toBe( + "checkout.orderform.profile", + ); + expect( + vtexOperationRouter(`${acct}/api/checkout/pub/orderForm/${id}/shippingData/...`, "POST"), + ).toBe("checkout.orderform.shipping"); + expect( + vtexOperationRouter(`${acct}/api/checkout/pub/orderForm/${id}/paymentData/...`, "POST"), + ).toBe("checkout.orderform.payment"); + }); + + it("matches the singleton orderForm/{id} root with the right method", () => { + expect(vtexOperationRouter(`${acct}/api/checkout/pub/orderForm/abc`, "GET")).toBe( + "checkout.orderform.get", + ); + expect(vtexOperationRouter(`${acct}/api/checkout/pub/orderForm/abc`, "PATCH")).toBe( + "checkout.orderform.update", + ); + }); + + it("maps simulation, regions, postal-code", () => { + expect(vtexOperationRouter(`${acct}/api/checkout/pub/orderForms/simulation`, "POST")).toBe( + "checkout.simulation", + ); + expect(vtexOperationRouter(`${acct}/api/checkout/pub/regions`, "GET")).toBe( + "checkout.regions", + ); + expect(vtexOperationRouter(`${acct}/api/checkout/pub/postal-code/BRA/01310`, "GET")).toBe( + "checkout.postal-code", + ); + }); + }); + + describe("Sessions + segments", () => { + it("differentiates sessions GET vs POST", () => { + expect(vtexOperationRouter(`${acct}/api/sessions`, "GET")).toBe("sessions.get"); + expect(vtexOperationRouter(`${acct}/api/sessions`, "POST")).toBe("sessions.update"); + }); + + it("matches segments", () => { + expect(vtexOperationRouter(`${acct}/api/segments/abc-123`, "GET")).toBe("segments.get"); + }); + }); + + describe("Catalog System", () => { + it("captures the most-specific catalog endpoints first", () => { + expect( + vtexOperationRouter(`${acct}/api/catalog_system/pub/portal/pagetype/eletronicos`, "GET"), + ).toBe("catalog.pagetype"); + expect( + vtexOperationRouter( + `${acct}/api/catalog_system/pub/products/crossselling/whoboughtalsobought/123`, + "GET", + ), + ).toBe("catalog.crossselling.whoboughtalsobought"); + expect( + vtexOperationRouter(`${acct}/api/catalog_system/pub/products/variations/123`, "GET"), + ).toBe("catalog.products.variations"); + expect( + vtexOperationRouter(`${acct}/api/catalog_system/pub/products/search/?fq=x`, "GET"), + ).toBe("catalog.products.search"); + expect(vtexOperationRouter(`${acct}/api/catalog_system/pub/facets/search/x`, "GET")).toBe( + "catalog.facets.search", + ); + expect(vtexOperationRouter(`${acct}/api/catalog_system/pub/category/tree/3`, "GET")).toBe( + "catalog.category.tree", + ); + expect( + vtexOperationRouter(`${acct}/api/catalog_system/pvt/sku/stockkeepingunitbyid/123`, "GET"), + ).toBe("catalog.sku"); + }); + + it("falls back to catalog.other for unrecognized catalog paths", () => { + expect( + vtexOperationRouter(`${acct}/api/catalog_system/pvt/specification/groupGet/123`, "GET"), + ).toBe("catalog.specification"); + expect(vtexOperationRouter(`${acct}/api/catalog_system/pub/brand/list`, "GET")).toBe( + "catalog.brand", + ); + }); + }); + + describe("Masterdata", () => { + it("encodes the entity name as the operation suffix", () => { + expect(vtexOperationRouter(`${acct}/api/dataentities/AD/search`, "GET")).toBe( + "masterdata.AD", + ); + expect(vtexOperationRouter(`${acct}/api/dataentities/wishlist_lists/documents`, "POST")).toBe( + "masterdata.wishlist_lists", + ); + }); + }); + + describe("OMS", () => { + it("differentiates orders list vs cancel", () => { + expect(vtexOperationRouter(`${acct}/api/oms/user/orders`, "GET")).toBe("oms.orders"); + expect(vtexOperationRouter(`${acct}/api/oms/user/orders/v999-01/cancel`, "POST")).toBe( + "oms.orders.cancel", + ); + expect(vtexOperationRouter(`${acct}/api/oms/pvt/orders/v999-01`, "GET")).toBe( + "oms.orders.pvt", + ); + }); + }); + + describe("VTEX ID", () => { + it("maps the auth surface", () => { + expect(vtexOperationRouter(`${acct}/api/vtexid/pub/logout?scope=x`, "GET")).toBe( + "vtexid.logout", + ); + expect(vtexOperationRouter(`${acct}/api/vtexid/pub/authentication/start`, "GET")).toBe( + "vtexid.authentication.start", + ); + expect( + vtexOperationRouter(`${acct}/api/vtexid/pub/authentication/classic/validate`, "POST"), + ).toBe("vtexid.authentication.validate"); + expect(vtexOperationRouter(`${acct}/api/vtexid/pub/authenticated/user`, "GET")).toBe( + "vtexid.user", + ); + }); + + it("falls back to vtexid.other for unmapped vtexid paths", () => { + expect(vtexOperationRouter(`${acct}/api/vtexid/pub/refreshtoken`, "POST")).toBe( + "vtexid.other", + ); + }); + }); + + describe("VTEX IO + GraphQL", () => { + it("matches the IO private graphql endpoint", () => { + expect(vtexOperationRouter(`https://store.myvtex.com/_v/private/graphql/v1`, "POST")).toBe( + "io.graphql", + ); + }); + + it("matches the IO segment endpoint", () => { + expect( + vtexOperationRouter(`https://store.myvtex.com/_v/segment/admin-pvt/whatever`, "GET"), + ).toBe("io.segment"); + }); + }); + + describe("Edge cases", () => { + it("returns undefined for fully unrecognized URLs", () => { + expect(vtexOperationRouter(`${acct}/somethingelse/random`, "GET")).toBeUndefined(); + }); + + it("does not throw on unparseable URLs and still tries to match", () => { + expect(vtexOperationRouter("not-a-real-url", "GET")).toBeUndefined(); + expect(vtexOperationRouter("/api/sessions?x=1", "GET")).toBe("sessions.get"); + }); + + it("is case-insensitive on the method", () => { + expect(vtexOperationRouter(`${acct}/api/sessions`, "post")).toBe("sessions.update"); + expect(vtexOperationRouter(`${acct}/api/sessions`, "Get")).toBe("sessions.get"); + }); + + it("recognizes sitemap.xml + sitemap-products-0.xml", () => { + expect(vtexOperationRouter(`${acct}/sitemap.xml`, "GET")).toBe("sitemap"); + expect(vtexOperationRouter(`${acct}/sitemap-products-0.xml`, "GET")).toBe("sitemap"); + }); + }); +}); diff --git a/vtex/utils/authHelpers.ts b/vtex/utils/authHelpers.ts index 3b53bb7..bc6d3da 100644 --- a/vtex/utils/authHelpers.ts +++ b/vtex/utils/authHelpers.ts @@ -6,7 +6,7 @@ * createServerFn itself must live in site source (not node_modules) because * TanStack Start's Vite plugin only transforms source files. */ -import { getVtexConfig } from "../client"; +import { getVtexConfig, getVtexFetch } from "../client"; import { extractVtexCookies } from "./cookieSanitizer"; const DOMAIN_RE = /;\s*domain=[^;]*/gi; @@ -49,10 +49,11 @@ export async function performVtexLogout(cookies: string): Promise<{ setCookies: const domain = config.domain ?? "com.br"; const logoutUrl = `https://${config.account}.vtexcommercestable.${domain}/api/vtexid/pub/logout?scope=${config.account}&returnUrl=/`; - const res = await fetch(logoutUrl, { + const res = await getVtexFetch()(logoutUrl, { method: "GET", headers: { cookie: cookies }, redirect: "manual", + operation: "vtexid.logout", }); const upstreamCookies = res.headers.getSetCookie?.() ?? []; diff --git a/vtex/utils/instrumentedFetch.ts b/vtex/utils/instrumentedFetch.ts new file mode 100644 index 0000000..c93f9f3 --- /dev/null +++ b/vtex/utils/instrumentedFetch.ts @@ -0,0 +1,81 @@ +/** + * Pre-wired instrumented fetch factory for VTEX. + * + * Bundles the three pieces a storefront would otherwise have to wire + * by hand: + * + * 1. The `createInstrumentedFetch` boundary from `@decocms/start` + * (spans, traceparent injection, URL redaction, cache-header + * span attributes). + * 2. The `vtexOperationRouter` URL→operation mapping so unannotated + * callsites still get semantic span names + histogram labels. + * 3. An `onComplete` callback that records every call into the + * `commerce_request_duration_ms` histogram via the meter + * configured by `instrumentWorker(...)` in + * `@decocms/start/sdk/observability` — `provider`, `operation`, + * `status_code`, and `cached` labels. + * + * Sites opt in once at startup: + * + * ```ts + * import { setVtexFetch } from "@decocms/apps/vtex"; + * import { createVtexFetch } from "@decocms/apps/vtex"; + * + * setVtexFetch(createVtexFetch()); + * ``` + * + * Sites that need to wrap a custom underlying fetch (cookie passthrough, + * proxy, retry, etc.) pass it as `baseFetch`. The instrumentation + * still applies — `createInstrumentedFetch` preserves the wrapped + * behavior. + */ + +import { + createInstrumentedFetch, + type InstrumentedFetch, +} from "@decocms/start/sdk/instrumentedFetch"; +import { getMeter } from "@decocms/start/sdk/observability"; +import { vtexOperationRouter } from "./operationRouter"; + +const HISTOGRAM_NAME = "commerce_request_duration_ms"; + +export interface CreateVtexFetchOptions { + /** + * Underlying fetch to wrap. Defaults to `globalThis.fetch`. + * Pass an existing custom fetch (e.g. one that injects auth cookies + * or routes through a proxy) to preserve its behavior while adding + * the VTEX instrumentation layer on top. + */ + baseFetch?: typeof fetch; + /** + * Disable the `commerce_request_duration_ms` histogram emission. + * The framework's span and structured logs still emit. Useful when + * the consumer wants to record its own histogram with a custom shape. + * Default: false. + */ + disableHistogram?: boolean; +} + +/** + * Construct a pre-wired VTEX `InstrumentedFetch`. Pass the result to + * `setVtexFetch(...)`. See module docstring for details. + */ +export function createVtexFetch(options: CreateVtexFetchOptions = {}): InstrumentedFetch { + const { baseFetch, disableHistogram = false } = options; + return createInstrumentedFetch({ + name: "vtex", + baseFetch, + resolveOperation: vtexOperationRouter, + onComplete: disableHistogram + ? undefined + : ({ operation, status, durationMs, cached }) => { + const meter = getMeter(); + meter?.histogramRecord?.(HISTOGRAM_NAME, durationMs, { + provider: "vtex", + operation, + status_code: String(status), + cached: cached ? "true" : "false", + }); + }, + }); +} diff --git a/vtex/utils/operationRouter.ts b/vtex/utils/operationRouter.ts new file mode 100644 index 0000000..4566f71 --- /dev/null +++ b/vtex/utils/operationRouter.ts @@ -0,0 +1,139 @@ +/** + * URL-derived operation name router for VTEX API calls. + * + * Plugged into `@decocms/start`'s `createInstrumentedFetch` via the + * `resolveOperation(url, method)` option. The resolved string becomes the + * span suffix (`vtex.`) and the `fetch.operation` span + + * histogram label, so it must be: + * + * - low-cardinality (no IDs, slugs, search terms, account names); + * - stable across deploys (used for alerting + dashboards); + * - human-debuggable in a trace view. + * + * The router is intentionally a flat ordered list of regex matchers, + * not a tree. Adding/auditing routes is a one-line patch and routes + * are evaluated in priority order (most specific first). Unknown URLs + * return `undefined` so the framework falls back to the generic + * `vtex.fetch` span name — observable, just less specific. + * + * Callers that need finer granularity than the URL can express (e.g. + * `POST /orderForm/{id}/items` is one URL but covers add / update / + * remove flows) should set `init.operation` explicitly per call; that + * always wins over the router. + */ + +type OperationResolver = string | ((match: RegExpMatchArray, method: string) => string); + +interface Matcher { + pattern: RegExp; + operation: OperationResolver; +} + +const m = (pattern: RegExp, operation: OperationResolver): Matcher => ({ pattern, operation }); + +/** + * Ordered list of `(regex, operation)` matchers. The first match wins. + * + * Patterns match against the URL pathname (the host is ignored — VTEX + * spreads the same API surface across `*.vtexcommercestable.*`, + * `*.myvtex.com`, and storefront origins, all on identical paths). + * + * Operation strings are bare (no `vtex.` prefix) — the framework + * prefixes them with the integration name at span time. + */ +const MATCHERS: ReadonlyArray = [ + m(/^\/api\/io\/_v\/api\/intelligent-search\/([a-z_-]+)/, (mm) => `intelligent-search.${mm[1]}`), + m(/^\/_v\/private\/graphql\/v1/, "io.graphql"), + m(/^\/_v\/segment\//, "io.segment"), + + m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/items\/update/, (_mm, method) => + method === "POST" ? "checkout.orderform.items.update" : "checkout.orderform.items", + ), + m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/items/, (_mm, method) => { + if (method === "DELETE") return "checkout.orderform.items.remove"; + if (method === "PATCH" || method === "PUT") return "checkout.orderform.items.update"; + return "checkout.orderform.items.add"; + }), + m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/coupons/, "checkout.orderform.coupons"), + m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/profile/, "checkout.orderform.profile"), + m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/shippingData/, "checkout.orderform.shipping"), + m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/paymentData/, "checkout.orderform.payment"), + m(/^\/api\/checkout\/pub\/orderForm\/[^/]+/, (_mm, method) => + method === "GET" ? "checkout.orderform.get" : "checkout.orderform.update", + ), + m(/^\/api\/checkout\/pub\/orderForm(?:\/?$)/, (_mm, method) => + method === "POST" ? "checkout.orderform.create" : "checkout.orderform.get", + ), + m(/^\/api\/checkout\/pub\/orderForms\/simulation/, "checkout.simulation"), + m(/^\/api\/checkout\/pub\/regions/, "checkout.regions"), + m(/^\/api\/checkout\/pub\/postal-code/, "checkout.postal-code"), + + m(/^\/api\/sessions/, (_mm, method) => (method === "POST" ? "sessions.update" : "sessions.get")), + m(/^\/api\/segments\//, "segments.get"), + + m(/^\/api\/catalog_system\/pub\/portal\/pagetype\//, "catalog.pagetype"), + m( + /^\/api\/catalog_system\/pub\/products\/crossselling\/([^/]+)/, + (mm) => `catalog.crossselling.${mm[1]}`, + ), + m(/^\/api\/catalog_system\/pub\/products\/variations\//, "catalog.products.variations"), + m(/^\/api\/catalog_system\/pub\/products\/search/, "catalog.products.search"), + m(/^\/api\/catalog_system\/pub\/facets\/search/, "catalog.facets.search"), + m(/^\/api\/catalog_system\/pub\/category\/tree/, "catalog.category.tree"), + m(/^\/api\/catalog_system\/(?:pub|pvt)\/specification/, "catalog.specification"), + m(/^\/api\/catalog_system\/pub\/brand/, "catalog.brand"), + m(/^\/api\/catalog_system\/pvt\/sku\//, "catalog.sku"), + m(/^\/api\/catalog_system\//, "catalog.other"), + + m(/^\/api\/wishlist\//, "wishlist"), + m(/^\/api\/profile-system\/profile\//, "profile"), + m(/^\/api\/dataentities\/([^/]+)/, (mm) => `masterdata.${mm[1]}`), + + m(/^\/api\/oms\/user\/orders\/[^/]+\/cancel/, "oms.orders.cancel"), + m(/^\/api\/oms\/user\/orders/, "oms.orders"), + m(/^\/api\/oms\/pvt\/orders/, "oms.orders.pvt"), + + m(/^\/api\/vtexid\/pub\/logout/, "vtexid.logout"), + m(/^\/api\/vtexid\/pub\/authentication\/start/, "vtexid.authentication.start"), + m(/^\/api\/vtexid\/pub\/authentication\/[a-z]+\/validate/, "vtexid.authentication.validate"), + m(/^\/api\/vtexid\/pub\/authenticated\/user/, "vtexid.user"), + m(/^\/api\/vtexid\//, "vtexid.other"), + + m(/^\/api\/events\/v1\//, "events.send"), + m(/^\/sitemap.*\.xml$/, "sitemap"), + m(/^\/api\/license-manager/, "license-manager"), +]; + +/** + * Resolve an operation name for a VTEX URL. Returns `undefined` if no + * matcher fires, which causes the framework to fall back to + * `vtex.fetch`. + * + * Designed to be passed directly to `createInstrumentedFetch`: + * + * ```ts + * createInstrumentedFetch({ + * name: "vtex", + * resolveOperation: vtexOperationRouter, + * }); + * ``` + */ +export function vtexOperationRouter(url: string, method: string): string | undefined { + let pathname: string; + try { + pathname = new URL(url).pathname; + } catch { + const qs = url.indexOf("?"); + const hash = url.indexOf("#"); + const end = [qs, hash].filter((i) => i >= 0).sort((a, b) => a - b)[0]; + pathname = end === undefined ? url : url.slice(0, end); + } + + const upperMethod = method.toUpperCase(); + for (const { pattern, operation } of MATCHERS) { + const match = pathname.match(pattern); + if (!match) continue; + return typeof operation === "function" ? operation(match, upperMethod) : operation; + } + return undefined; +} diff --git a/vtex/utils/proxy.ts b/vtex/utils/proxy.ts index a4eb557..bff7e04 100644 --- a/vtex/utils/proxy.ts +++ b/vtex/utils/proxy.ts @@ -14,7 +14,7 @@ * fetch handlers. */ -import { getVtexConfig, type VtexConfig, vtexHost } from "../client"; +import { getVtexConfig, getVtexFetch, type VtexConfig, vtexHost } from "../client"; import { proxySetCookie } from "./cookies"; export interface VtexProxyOptions { @@ -173,7 +173,12 @@ export async function proxyToVtex(request: Request, options?: VtexProxyOptions): init.duplex = "half"; } - const originResponse = await fetch(originUrl.toString(), init); + // Route through the configured VTEX fetch so traces / metrics / logs + // see the proxied origin call. The URL router classifies the call + // into the right `vtex..` bucket (e.g. `vtex.checkout.*`, + // `vtex.vtexid.logout`, `vtex.io.segment`) — no per-callsite hint + // needed because we're a generic forwarder. + const originResponse = await getVtexFetch()(originUrl.toString(), init); const responseHeaders = filterHeaders(new Headers(originResponse.headers)); @@ -380,7 +385,7 @@ export function createVtexCheckoutProxy( init.duplex = "half"; } - const originRes = await fetch(originUrl.toString(), init); + const originRes = await getVtexFetch()(originUrl.toString(), init); const resHeaders = filterHeadersStrict(new Headers(originRes.headers)); rewriteSetCookieDomain(originRes.headers, resHeaders, url.hostname);