@decocms/apps 1.14.0-next.1 → 1.15.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -57,8 +57,11 @@ A working VTEX storefront needs three things: a `deco-vtex` config block, an `in
57
57
 
58
58
  ```ts
59
59
  import { createSiteSetup } from "@decocms/start/setup";
60
- import { createInstrumentedFetch } from "@decocms/start/sdk/instrumentedFetch";
61
- import { initVtexFromBlocks, setVtexFetch } from "@decocms/apps/vtex/client";
60
+ import {
61
+ createVtexFetch,
62
+ initVtexFromBlocks,
63
+ setVtexFetch,
64
+ } from "@decocms/apps/vtex";
62
65
  import { createVtexCommerceLoaders } from "@decocms/apps/vtex/commerceLoaders";
63
66
 
64
67
  createSiteSetup({
@@ -69,9 +72,28 @@ createSiteSetup({
69
72
  getCommerceLoaders: () => createVtexCommerceLoaders(),
70
73
  });
71
74
 
72
- setVtexFetch(createInstrumentedFetch("vtex"));
75
+ // Plumbs spans, traceparent injection, URL redaction, and the
76
+ // `commerce_request_duration_ms` histogram into every outbound
77
+ // VTEX call. Operation names are derived from the URL via
78
+ // `vtexOperationRouter` (overridable per call via `init.operation`).
79
+ setVtexFetch(createVtexFetch());
73
80
  ```
74
81
 
82
+ For Shopify storefronts the equivalent factory is `createShopifyFetch()`:
83
+
84
+ ```ts
85
+ import { createShopifyFetch, setShopifyFetch } from "@decocms/apps/shopify";
86
+
87
+ setShopifyFetch(createShopifyFetch());
88
+ ```
89
+
90
+ Shopify's GraphQL operation name (`query Foo { ... }` → `Foo`) is
91
+ extracted from the document body and stamped automatically — spans
92
+ become `shopify.Foo` instead of the generic `shopify.storefront.graphql`.
93
+
94
+ > **Heads up:** the factories require `@decocms/start@>=5.3.0-rc.0`
95
+ > for the per-call `init.operation` API used to label spans.
96
+
75
97
  #### 3. Hooks in components
76
98
 
77
99
  ```tsx
@@ -134,7 +156,9 @@ await sendEmail({
134
156
  | Subpath | Purpose |
135
157
  |---------|---------|
136
158
  | `@decocms/apps/vtex` | Barrel index |
137
- | `@decocms/apps/vtex/client` | `vtexFetch`, `vtexFetchWithCookies`, `intelligentSearch`, `setVtexFetch`, `initVtexFromBlocks`, `configureVtex` |
159
+ | `@decocms/apps/vtex/client` | `vtexFetch`, `vtexFetchWithCookies`, `intelligentSearch`, `setVtexFetch`, `getVtexFetch`, `initVtexFromBlocks`, `configureVtex` |
160
+ | `@decocms/apps/vtex` (barrel) | All of `client`, plus `createVtexFetch`, `vtexOperationRouter` for observability wiring |
161
+ | `@decocms/apps/shopify` (barrel) | `createShopifyFetch`, `setShopifyFetch`, `shopifyOperationRouter`, `extractGraphqlOperationName` for observability wiring |
138
162
  | `@decocms/apps/vtex/commerceLoaders` | `createVtexCommerceLoaders` |
139
163
  | `@decocms/apps/vtex/loaders/*` | Cart, user, wishlist, search, catalog, sessions, orders, autocomplete |
140
164
  | `@decocms/apps/vtex/actions/*` | Cart mutations, auth, profile, address, wishlist, newsletter |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/apps",
3
- "version": "1.14.0-next.1",
3
+ "version": "1.15.0-next.1",
4
4
  "type": "module",
5
5
  "description": "Deco commerce apps for TanStack Start - Shopify, VTEX, commerce types, analytics utils",
6
6
  "exports": {
@@ -109,14 +109,14 @@
109
109
  "access": "public"
110
110
  },
111
111
  "peerDependencies": {
112
- "@decocms/start": ">=0.19.0",
112
+ "@decocms/start": ">=5.3.0-rc.0",
113
113
  "@tanstack/react-query": ">=5",
114
114
  "react": ">=18",
115
115
  "react-dom": ">=18"
116
116
  },
117
117
  "devDependencies": {
118
118
  "@biomejs/biome": "^2.4.7",
119
- "@decocms/start": "^2.5.0",
119
+ "@decocms/start": "5.3.0-rc.0",
120
120
  "@semantic-release/exec": "^7.1.0",
121
121
  "@tanstack/react-query": "^5.90.21",
122
122
  "@types/react": "^19.0.0",
package/shopify/index.ts CHANGED
@@ -34,4 +34,10 @@ export { default as userLoader } from "./loaders/user";
34
34
  export { getCartCookie, setCartCookie } from "./utils/cart";
35
35
  // Cookie utils
36
36
  export { getCookies, setCookie } from "./utils/cookies";
37
+ export { extractGraphqlOperationName } from "./utils/graphqlOperationName";
38
+ export {
39
+ type CreateShopifyFetchOptions,
40
+ createShopifyFetch,
41
+ } from "./utils/instrumentedFetch";
42
+ export { shopifyOperationRouter } from "./utils/operationRouter";
37
43
  export { getUserCookie, setUserCookie } from "./utils/user";
@@ -1,3 +1,6 @@
1
+ import type { InstrumentedFetchInit } from "@decocms/start/sdk/instrumentedFetch";
2
+ import { extractGraphqlOperationName } from "./graphqlOperationName";
3
+
1
4
  export function gql(strings: TemplateStringsArray, ...values: unknown[]): string {
2
5
  return strings.reduce((acc, str, i) => acc + str + (values[i] ?? ""), "");
3
6
  }
@@ -29,14 +32,22 @@ export function createGraphqlClient(
29
32
  ): Promise<T> {
30
33
  const query = typeof queryOrDef === "string" ? queryOrDef : buildQuery(queryOrDef);
31
34
 
32
- const response = await _fetch(endpoint, {
35
+ // Stamp the GraphQL operation as init.operation so the framework's
36
+ // span name becomes `shopify.<OperationName>` instead of the
37
+ // generic `shopify.storefront.graphql` from the URL router. The
38
+ // extra field is silently dropped by plain `fetch` and read by
39
+ // any `InstrumentedFetch` configured via `setShopifyFetch`.
40
+ const operation = extractGraphqlOperationName(query);
41
+ const init: InstrumentedFetchInit = {
33
42
  method: "POST",
34
43
  headers: {
35
44
  "Content-Type": "application/json",
36
45
  ...headers,
37
46
  },
38
47
  body: JSON.stringify({ query, variables }),
39
- });
48
+ ...(operation ? { operation } : {}),
49
+ };
50
+ const response = await _fetch(endpoint, init);
40
51
 
41
52
  if (!response.ok) {
42
53
  throw new Error(`Shopify GraphQL error: ${response.status} ${response.statusText}`);
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Extract a semantic operation name from a GraphQL document.
3
+ *
4
+ * Used at the Shopify GraphQL client layer to stamp `init.operation`
5
+ * on the outbound fetch. The framework then suffixes the integration
6
+ * name (`shopify.<operation>`) onto the span and uses the same string
7
+ * as the `fetch.operation` attribute + histogram label.
8
+ *
9
+ * Resolution order:
10
+ *
11
+ * 1. An explicit `operationName` argument (e.g. when the client
12
+ * received one alongside a multi-operation document) wins.
13
+ * 2. If the document has exactly one named operation, that name
14
+ * is used.
15
+ * 3. If the document has zero or many anonymous operations, we
16
+ * return `undefined` so the caller can fall back (typically to
17
+ * the URL-derived `storefront.graphql` / `admin.graphql`).
18
+ *
19
+ * The parser is deliberately a small regex pass, not a full GraphQL
20
+ * tokenizer:
21
+ *
22
+ * - GraphQL operation definitions live at the top level of the
23
+ * document, never nested inside other operations, fragments, or
24
+ * selection sets, so positional context isn't required to find
25
+ * them — only to not match the literal words `query` /
26
+ * `mutation` / `subscription` inside string values.
27
+ * - We strip block strings (`""" … """`), string literals
28
+ * (`"…"`), and `# …` comments before matching, which is enough
29
+ * to make false-positive matches inside comments / docs vanish.
30
+ *
31
+ * If a Shopify operation is ever sufficiently mis-named to break
32
+ * this (unlikely, since the storefront SDK names them deliberately),
33
+ * the caller can always set `init.operation` explicitly.
34
+ */
35
+
36
+ const OPERATION_RE = /\b(?:query|mutation|subscription)\s+([A-Za-z_][A-Za-z0-9_]*)/g;
37
+
38
+ const stripCommentsAndStrings = (doc: string): string =>
39
+ doc
40
+ .replace(/"""[\s\S]*?"""/g, '""')
41
+ .replace(/"(?:\\.|[^"\\])*"/g, '""')
42
+ .replace(/#[^\n]*/g, "");
43
+
44
+ export function extractGraphqlOperationName(
45
+ document: string,
46
+ explicit?: string,
47
+ ): string | undefined {
48
+ if (explicit) return explicit;
49
+ if (!document) return undefined;
50
+
51
+ const stripped = stripCommentsAndStrings(document);
52
+ const names: string[] = [];
53
+
54
+ OPERATION_RE.lastIndex = 0;
55
+ for (
56
+ let match = OPERATION_RE.exec(stripped);
57
+ match !== null;
58
+ match = OPERATION_RE.exec(stripped)
59
+ ) {
60
+ const [, name] = match;
61
+ if (name) names.push(name);
62
+ if (names.length > 1) break;
63
+ }
64
+
65
+ if (names.length === 1) return names[0];
66
+ return undefined;
67
+ }
@@ -0,0 +1,57 @@
1
+ /**
2
+ * Pre-wired instrumented fetch factory for Shopify.
3
+ *
4
+ * Mirrors `vtex/utils/instrumentedFetch.ts`. Bundles:
5
+ *
6
+ * 1. `createInstrumentedFetch` from `@decocms/start` (spans,
7
+ * traceparent, URL redaction).
8
+ * 2. `shopifyOperationRouter` as the URL fallback for non-GraphQL
9
+ * and unnamed-GraphQL calls.
10
+ * 3. An `onComplete` that records `commerce_request_duration_ms`
11
+ * with `provider: "shopify"`.
12
+ *
13
+ * Sites do:
14
+ *
15
+ * ```ts
16
+ * import { setShopifyFetch, createShopifyFetch } from "@decocms/apps/shopify";
17
+ * setShopifyFetch(createShopifyFetch());
18
+ * ```
19
+ *
20
+ * Per-call operation names come from `extractGraphqlOperationName`
21
+ * (wired in `./graphql.ts`); the URL router fires only when the
22
+ * extractor returns `undefined`.
23
+ */
24
+
25
+ import {
26
+ createInstrumentedFetch,
27
+ type InstrumentedFetch,
28
+ } from "@decocms/start/sdk/instrumentedFetch";
29
+ import { getMeter } from "@decocms/start/sdk/observability";
30
+ import { shopifyOperationRouter } from "./operationRouter";
31
+
32
+ const HISTOGRAM_NAME = "commerce_request_duration_ms";
33
+
34
+ export interface CreateShopifyFetchOptions {
35
+ baseFetch?: typeof fetch;
36
+ disableHistogram?: boolean;
37
+ }
38
+
39
+ export function createShopifyFetch(options: CreateShopifyFetchOptions = {}): InstrumentedFetch {
40
+ const { baseFetch, disableHistogram = false } = options;
41
+ return createInstrumentedFetch({
42
+ name: "shopify",
43
+ baseFetch,
44
+ resolveOperation: shopifyOperationRouter,
45
+ onComplete: disableHistogram
46
+ ? undefined
47
+ : ({ operation, status, durationMs, cached }) => {
48
+ const meter = getMeter();
49
+ meter?.histogramRecord?.(HISTOGRAM_NAME, durationMs, {
50
+ provider: "shopify",
51
+ operation,
52
+ status_code: String(status),
53
+ cached: cached ? "true" : "false",
54
+ });
55
+ },
56
+ });
57
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * URL-derived operation name router for Shopify API calls.
3
+ *
4
+ * Plugged into `@decocms/start`'s `createInstrumentedFetch` via the
5
+ * `resolveOperation(url, method)` option. Mirrors the shape of the
6
+ * VTEX router in `../../vtex/utils/operationRouter.ts`.
7
+ *
8
+ * Shopify's API surface from this repo is overwhelmingly GraphQL —
9
+ * a single endpoint per environment (storefront vs admin). That means
10
+ * the URL alone can only tell us *which GraphQL surface* a call is
11
+ * hitting, not what the call actually does. The semantic operation
12
+ * name lives in the GraphQL document itself (`query Foo { ... }`),
13
+ * and is extracted by `extractGraphqlOperationName` (see
14
+ * `./graphqlOperationName.ts`) at the client layer and stamped as
15
+ * `init.operation`, which always wins over this router.
16
+ *
17
+ * So this router exists for:
18
+ *
19
+ * - non-GraphQL Shopify REST endpoints we may add later
20
+ * (cart API, customer accounts, billing, etc.);
21
+ * - giving the GraphQL endpoints a *fallback* operation when the
22
+ * extractor can't parse a name (anonymous queries, missing body).
23
+ */
24
+
25
+ type OperationResolver = string | ((match: RegExpMatchArray, method: string) => string);
26
+
27
+ interface Matcher {
28
+ pattern: RegExp;
29
+ operation: OperationResolver;
30
+ }
31
+
32
+ const m = (pattern: RegExp, operation: OperationResolver): Matcher => ({ pattern, operation });
33
+
34
+ const MATCHERS: ReadonlyArray<Matcher> = [
35
+ m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/graphql\.json/, "admin.graphql"),
36
+ m(/^\/api\/[0-9]{4}-[0-9]{2}\/graphql\.json/, "storefront.graphql"),
37
+
38
+ m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/products/, "admin.products"),
39
+ m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/orders/, "admin.orders"),
40
+ m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/customers/, "admin.customers"),
41
+ m(/^\/admin\/api\/[0-9]{4}-[0-9]{2}\/inventory/, "admin.inventory"),
42
+
43
+ m(/^\/api\/[0-9]{4}-[0-9]{2}\/checkouts/, "storefront.checkout"),
44
+ m(/^\/cart(?:\/|\.js|$)/, "storefront.cart"),
45
+ ];
46
+
47
+ /**
48
+ * Resolve an operation name for a Shopify URL. Returns `undefined`
49
+ * if no matcher fires, which causes the framework to fall back to
50
+ * `shopify.fetch`.
51
+ */
52
+ export function shopifyOperationRouter(url: string, method: string): string | undefined {
53
+ let pathname: string;
54
+ try {
55
+ pathname = new URL(url).pathname;
56
+ } catch {
57
+ const qs = url.indexOf("?");
58
+ const hash = url.indexOf("#");
59
+ const end = [qs, hash].filter((i) => i >= 0).sort((a, b) => a - b)[0];
60
+ pathname = end === undefined ? url : url.slice(0, end);
61
+ }
62
+
63
+ const upperMethod = method.toUpperCase();
64
+ for (const { pattern, operation } of MATCHERS) {
65
+ const match = pathname.match(pattern);
66
+ if (!match) continue;
67
+ return typeof operation === "function" ? operation(match, upperMethod) : operation;
68
+ }
69
+ return undefined;
70
+ }
@@ -8,7 +8,7 @@
8
8
  * @see https://developers.vtex.com/docs/api-reference/intelligent-search-api#post-/event-api/v1/-account-/event
9
9
  */
10
10
 
11
- import { getVtexConfig } from "../../client";
11
+ import { getVtexConfig, getVtexFetch } from "../../client";
12
12
  import { ANONYMOUS_COOKIE, SESSION_COOKIE } from "../../utils/intelligentSearch";
13
13
 
14
14
  export type Props =
@@ -68,7 +68,7 @@ const action = async (props: Props, req: Request): Promise<null> => {
68
68
  }
69
69
 
70
70
  const url = `https://sp.vtex.com/event-api/v1/${account}/event`;
71
- await fetch(url, {
71
+ await getVtexFetch()(url, {
72
72
  method: "POST",
73
73
  headers: { "content-type": "application/json" },
74
74
  body: JSON.stringify({
@@ -77,6 +77,7 @@ const action = async (props: Props, req: Request): Promise<null> => {
77
77
  anonymous,
78
78
  agent: req.headers.get("user-agent") || "deco-sites/apps",
79
79
  }),
80
+ operation: "analytics.event",
80
81
  });
81
82
 
82
83
  return null;
@@ -2,7 +2,7 @@
2
2
  * VTEX MasterData v2 API actions.
3
3
  * Generic CRUD operations on data entities.
4
4
  */
5
- import { getVtexConfig, vtexFetch, vtexFetchResponse } from "../client";
5
+ import { getVtexConfig, getVtexFetch, vtexFetch, vtexFetchResponse } from "../client";
6
6
 
7
7
  function removeEmptyFields(obj: Record<string, any>): Record<string, any> {
8
8
  return Object.fromEntries(
@@ -153,10 +153,11 @@ export async function uploadAttachment(opts: UploadAttachmentOpts): Promise<{ ok
153
153
  headers["X-VTEX-API-AppToken"] = config.appToken;
154
154
  }
155
155
 
156
- const response = await fetch(url, {
156
+ const response = await getVtexFetch()(url, {
157
157
  method: "POST",
158
158
  headers,
159
159
  body: formData,
160
+ operation: "masterdata.attachment.upload",
160
161
  });
161
162
 
162
163
  if (!response.ok) {
@@ -7,7 +7,7 @@
7
7
  * - vtex/actions/payment/deletePaymentToken.ts
8
8
  * @see https://developers.vtex.com/docs/api-reference
9
9
  */
10
- import { getVtexConfig, vtexFetch } from "../client";
10
+ import { getVtexConfig, getVtexFetch, vtexFetch } from "../client";
11
11
  import { buildAuthCookieHeader, VTEX_AUTH_COOKIE } from "../utils/vtexId";
12
12
 
13
13
  // ---------------------------------------------------------------------------
@@ -127,9 +127,10 @@ export async function notifyMe(props: NotifyMeProps): Promise<void> {
127
127
  form.append("notifymeClientEmail", email);
128
128
  form.append("notifymeIdSku", skuId);
129
129
 
130
- await fetch(`https://${account}.vtexcommercestable.com.br/no-cache/AviseMe.aspx`, {
130
+ await getVtexFetch()(`https://${account}.vtexcommercestable.com.br/no-cache/AviseMe.aspx`, {
131
131
  method: "POST",
132
132
  body: form,
133
+ operation: "notifyme",
133
134
  });
134
135
  }
135
136
 
@@ -148,7 +149,7 @@ export async function sendEvent(
148
149
  ): Promise<void> {
149
150
  const { account } = getVtexConfig();
150
151
 
151
- await fetch(`https://sp.vtex.com/event-api/v1/${account}/event`, {
152
+ await getVtexFetch()(`https://sp.vtex.com/event-api/v1/${account}/event`, {
152
153
  method: "POST",
153
154
  headers: { "Content-Type": "application/json" },
154
155
  body: JSON.stringify({
@@ -156,6 +157,7 @@ export async function sendEvent(
156
157
  ...isCookies,
157
158
  agent: userAgent || "deco-sites/apps",
158
159
  }),
160
+ operation: "analytics.event",
159
161
  });
160
162
  }
161
163
 
@@ -5,7 +5,7 @@
5
5
  * - vtex/actions/newsletter/updateNewsletterOptIn.ts
6
6
  * @see https://developers.vtex.com/docs/guides/newsletter
7
7
  */
8
- import { getVtexConfig, vtexFetch } from "../client";
8
+ import { getVtexConfig, getVtexFetch, vtexFetch } from "../client";
9
9
  import { buildAuthCookieHeader } from "../utils/vtexId";
10
10
 
11
11
  // ---------------------------------------------------------------------------
@@ -83,9 +83,10 @@ export async function subscribe(props: SubscribeProps): Promise<void> {
83
83
  form.append("newsInternalPart", part);
84
84
  form.append("newsInternalCampaign", campaing);
85
85
 
86
- await fetch(`https://${account}.vtexcommercestable.com.br/no-cache/Newsletter.aspx`, {
86
+ await getVtexFetch()(`https://${account}.vtexcommercestable.com.br/no-cache/Newsletter.aspx`, {
87
87
  method: "POST",
88
88
  body: form,
89
+ operation: "newsletter.subscribe",
89
90
  });
90
91
  }
91
92
 
@@ -4,7 +4,7 @@
4
4
  * - vtex/actions/profile/updateProfile.ts
5
5
  * @see https://developers.vtex.com/docs/guides/profile-system
6
6
  */
7
- import { getVtexConfig, vtexFetch } from "../client";
7
+ import { getVtexConfig, getVtexFetch, vtexFetch } from "../client";
8
8
  import { buildAuthCookieHeader } from "../utils/vtexId";
9
9
 
10
10
  // ---------------------------------------------------------------------------
@@ -166,10 +166,11 @@ export async function updateProfileFromRequest(
166
166
  corporateDocument tradeName stateRegistration
167
167
  }
168
168
  }`;
169
- const res = await fetch(`https://${account}.myvtex.com/_v/private/graphql/v1`, {
169
+ const res = await getVtexFetch()(`https://${account}.myvtex.com/_v/private/graphql/v1`, {
170
170
  method: "POST",
171
171
  body: JSON.stringify({ query: QUERY, variables: { profile } }),
172
172
  headers: { "Content-Type": "application/json", cookie },
173
+ operation: "io.graphql.UpdateProfile",
173
174
  });
174
175
  return res.json();
175
176
  }
package/vtex/client.ts CHANGED
@@ -3,6 +3,10 @@
3
3
  * Uses VTEX's public REST APIs (Intelligent Search + Catalog + Checkout).
4
4
  */
5
5
 
6
+ import type {
7
+ InstrumentedFetch,
8
+ InstrumentedFetchInit,
9
+ } from "@decocms/start/sdk/instrumentedFetch";
6
10
  import { RequestContext } from "@decocms/start/sdk/requestContext";
7
11
  import { sanitizeOutboundCookieHeader, warnDroppedCookies } from "./utils/cookieSanitizer";
8
12
  import { type FetchCacheOptions, fetchWithCache } from "./utils/fetchCache";
@@ -96,7 +100,7 @@ export interface VtexConfig {
96
100
  }
97
101
 
98
102
  let _config: VtexConfig | null = null;
99
- let _fetch: typeof fetch = globalThis.fetch;
103
+ let _fetch: typeof fetch | InstrumentedFetch = globalThis.fetch;
100
104
 
101
105
  export function configureVtex(config: VtexConfig) {
102
106
  _config = config;
@@ -105,19 +109,40 @@ export function configureVtex(config: VtexConfig) {
105
109
 
106
110
  /**
107
111
  * Override the fetch function used by all VTEX client calls.
108
- * Use this to plug in instrumented fetch for logging/tracing.
112
+ * Pass an `InstrumentedFetch` to get spans, traceparent injection,
113
+ * URL redaction, and the `commerce_request_duration_ms` histogram —
114
+ * use the pre-wired `createVtexFetch()` factory:
109
115
  *
110
- * @example
111
116
  * ```ts
112
- * import { createInstrumentedFetch } from "@decocms/start/sdk/instrumentedFetch";
113
- * import { setVtexFetch } from "@decocms/apps/vtex";
114
- * setVtexFetch(createInstrumentedFetch("vtex"));
117
+ * import { setVtexFetch, createVtexFetch } from "@decocms/apps/vtex";
118
+ * setVtexFetch(createVtexFetch());
115
119
  * ```
120
+ *
121
+ * Accepts a plain `typeof fetch` too; in that mode VTEX calls are
122
+ * uninstrumented (useful for tests + sites that haven't onboarded
123
+ * the observability stack yet).
116
124
  */
117
- export function setVtexFetch(fetchFn: typeof fetch) {
125
+ export function setVtexFetch(fetchFn: typeof fetch | InstrumentedFetch) {
118
126
  _fetch = fetchFn;
119
127
  }
120
128
 
129
+ /**
130
+ * Read-only accessor for the configured VTEX fetch. Used by ad-hoc
131
+ * callsites that don't fit the `vtexFetch*` helpers (FormData
132
+ * uploads, the storefront proxies, .aspx endpoints) but still want
133
+ * to participate in the instrumentation set up via `setVtexFetch`.
134
+ *
135
+ * Callers can stamp a per-call operation through the init:
136
+ *
137
+ * ```ts
138
+ * const fetch = getVtexFetch();
139
+ * await fetch(url, { method: "POST", operation: "notifyme" });
140
+ * ```
141
+ */
142
+ export function getVtexFetch(): InstrumentedFetch {
143
+ return _fetch as InstrumentedFetch;
144
+ }
145
+
121
146
  export function getVtexConfig(): VtexConfig {
122
147
  if (!_config) throw new Error("VTEX not configured. Call configureVtex() first.");
123
148
  return _config;
@@ -198,7 +223,10 @@ function hasCookieHeader(headers: HeadersInit | undefined): boolean {
198
223
  return Object.keys(headers).some((k) => k.toLowerCase() === "cookie");
199
224
  }
200
225
 
201
- export async function vtexFetchResponse(path: string, init?: RequestInit): Promise<Response> {
226
+ export async function vtexFetchResponse(
227
+ path: string,
228
+ init?: InstrumentedFetchInit,
229
+ ): Promise<Response> {
202
230
  const raw = path.startsWith("http") ? path : `${baseUrl()}${path}`;
203
231
  const url = sanitizeUrl(raw);
204
232
 
@@ -225,7 +253,7 @@ export async function vtexFetchResponse(path: string, init?: RequestInit): Promi
225
253
  return response;
226
254
  }
227
255
 
228
- export async function vtexFetch<T>(path: string, init?: RequestInit): Promise<T> {
256
+ export async function vtexFetch<T>(path: string, init?: InstrumentedFetchInit): Promise<T> {
229
257
  const response = await vtexFetchResponse(path, init);
230
258
  return response.json();
231
259
  }
@@ -242,7 +270,7 @@ export interface VtexCachedFetchOptions {
242
270
  */
243
271
  export async function vtexCachedFetch<T>(
244
272
  path: string,
245
- init?: RequestInit,
273
+ init?: InstrumentedFetchInit,
246
274
  cacheOpts?: VtexCachedFetchOptions,
247
275
  ): Promise<T | null> {
248
276
  const method = (init?.method ?? "GET").toUpperCase();
@@ -276,7 +304,10 @@ export async function vtexCachedFetch<T>(
276
304
  *
277
305
  * This mirrors deco-cx/deco's `proxySetCookie(response.headers, ctx.response.headers)`.
278
306
  */
279
- export async function vtexFetchWithCookies<T>(path: string, init?: RequestInit): Promise<T> {
307
+ export async function vtexFetchWithCookies<T>(
308
+ path: string,
309
+ init?: InstrumentedFetchInit,
310
+ ): Promise<T> {
280
311
  // Auto-inject request cookies from RequestContext.
281
312
  //
282
313
  // We sanitize the forwarded Cookie header before sending it to VTEX:
package/vtex/index.ts CHANGED
@@ -13,3 +13,5 @@
13
13
  */
14
14
  export * from "./client";
15
15
  export { configure, type VtexState } from "./mod";
16
+ export { type CreateVtexFetchOptions, createVtexFetch } from "./utils/instrumentedFetch";
17
+ export { vtexOperationRouter } from "./utils/operationRouter";
@@ -6,7 +6,7 @@
6
6
  * createServerFn itself must live in site source (not node_modules) because
7
7
  * TanStack Start's Vite plugin only transforms source files.
8
8
  */
9
- import { getVtexConfig } from "../client";
9
+ import { getVtexConfig, getVtexFetch } from "../client";
10
10
  import { extractVtexCookies } from "./cookieSanitizer";
11
11
 
12
12
  const DOMAIN_RE = /;\s*domain=[^;]*/gi;
@@ -49,10 +49,11 @@ export async function performVtexLogout(cookies: string): Promise<{ setCookies:
49
49
  const domain = config.domain ?? "com.br";
50
50
  const logoutUrl = `https://${config.account}.vtexcommercestable.${domain}/api/vtexid/pub/logout?scope=${config.account}&returnUrl=/`;
51
51
 
52
- const res = await fetch(logoutUrl, {
52
+ const res = await getVtexFetch()(logoutUrl, {
53
53
  method: "GET",
54
54
  headers: { cookie: cookies },
55
55
  redirect: "manual",
56
+ operation: "vtexid.logout",
56
57
  });
57
58
 
58
59
  const upstreamCookies = res.headers.getSetCookie?.() ?? [];
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Pre-wired instrumented fetch factory for VTEX.
3
+ *
4
+ * Bundles the three pieces a storefront would otherwise have to wire
5
+ * by hand:
6
+ *
7
+ * 1. The `createInstrumentedFetch` boundary from `@decocms/start`
8
+ * (spans, traceparent injection, URL redaction, cache-header
9
+ * span attributes).
10
+ * 2. The `vtexOperationRouter` URL→operation mapping so unannotated
11
+ * callsites still get semantic span names + histogram labels.
12
+ * 3. An `onComplete` callback that records every call into the
13
+ * `commerce_request_duration_ms` histogram via the meter
14
+ * configured by `instrumentWorker(...)` in
15
+ * `@decocms/start/sdk/observability` — `provider`, `operation`,
16
+ * `status_code`, and `cached` labels.
17
+ *
18
+ * Sites opt in once at startup:
19
+ *
20
+ * ```ts
21
+ * import { setVtexFetch } from "@decocms/apps/vtex";
22
+ * import { createVtexFetch } from "@decocms/apps/vtex";
23
+ *
24
+ * setVtexFetch(createVtexFetch());
25
+ * ```
26
+ *
27
+ * Sites that need to wrap a custom underlying fetch (cookie passthrough,
28
+ * proxy, retry, etc.) pass it as `baseFetch`. The instrumentation
29
+ * still applies — `createInstrumentedFetch` preserves the wrapped
30
+ * behavior.
31
+ */
32
+
33
+ import {
34
+ createInstrumentedFetch,
35
+ type InstrumentedFetch,
36
+ } from "@decocms/start/sdk/instrumentedFetch";
37
+ import { getMeter } from "@decocms/start/sdk/observability";
38
+ import { vtexOperationRouter } from "./operationRouter";
39
+
40
+ const HISTOGRAM_NAME = "commerce_request_duration_ms";
41
+
42
+ export interface CreateVtexFetchOptions {
43
+ /**
44
+ * Underlying fetch to wrap. Defaults to `globalThis.fetch`.
45
+ * Pass an existing custom fetch (e.g. one that injects auth cookies
46
+ * or routes through a proxy) to preserve its behavior while adding
47
+ * the VTEX instrumentation layer on top.
48
+ */
49
+ baseFetch?: typeof fetch;
50
+ /**
51
+ * Disable the `commerce_request_duration_ms` histogram emission.
52
+ * The framework's span and structured logs still emit. Useful when
53
+ * the consumer wants to record its own histogram with a custom shape.
54
+ * Default: false.
55
+ */
56
+ disableHistogram?: boolean;
57
+ }
58
+
59
+ /**
60
+ * Construct a pre-wired VTEX `InstrumentedFetch`. Pass the result to
61
+ * `setVtexFetch(...)`. See module docstring for details.
62
+ */
63
+ export function createVtexFetch(options: CreateVtexFetchOptions = {}): InstrumentedFetch {
64
+ const { baseFetch, disableHistogram = false } = options;
65
+ return createInstrumentedFetch({
66
+ name: "vtex",
67
+ baseFetch,
68
+ resolveOperation: vtexOperationRouter,
69
+ onComplete: disableHistogram
70
+ ? undefined
71
+ : ({ operation, status, durationMs, cached }) => {
72
+ const meter = getMeter();
73
+ meter?.histogramRecord?.(HISTOGRAM_NAME, durationMs, {
74
+ provider: "vtex",
75
+ operation,
76
+ status_code: String(status),
77
+ cached: cached ? "true" : "false",
78
+ });
79
+ },
80
+ });
81
+ }
@@ -0,0 +1,139 @@
1
+ /**
2
+ * URL-derived operation name router for VTEX API calls.
3
+ *
4
+ * Plugged into `@decocms/start`'s `createInstrumentedFetch` via the
5
+ * `resolveOperation(url, method)` option. The resolved string becomes the
6
+ * span suffix (`vtex.<operation>`) and the `fetch.operation` span +
7
+ * histogram label, so it must be:
8
+ *
9
+ * - low-cardinality (no IDs, slugs, search terms, account names);
10
+ * - stable across deploys (used for alerting + dashboards);
11
+ * - human-debuggable in a trace view.
12
+ *
13
+ * The router is intentionally a flat ordered list of regex matchers,
14
+ * not a tree. Adding/auditing routes is a one-line patch and routes
15
+ * are evaluated in priority order (most specific first). Unknown URLs
16
+ * return `undefined` so the framework falls back to the generic
17
+ * `vtex.fetch` span name — observable, just less specific.
18
+ *
19
+ * Callers that need finer granularity than the URL can express (e.g.
20
+ * `POST /orderForm/{id}/items` is one URL but covers add / update /
21
+ * remove flows) should set `init.operation` explicitly per call; that
22
+ * always wins over the router.
23
+ */
24
+
25
+ type OperationResolver = string | ((match: RegExpMatchArray, method: string) => string);
26
+
27
+ interface Matcher {
28
+ pattern: RegExp;
29
+ operation: OperationResolver;
30
+ }
31
+
32
+ const m = (pattern: RegExp, operation: OperationResolver): Matcher => ({ pattern, operation });
33
+
34
+ /**
35
+ * Ordered list of `(regex, operation)` matchers. The first match wins.
36
+ *
37
+ * Patterns match against the URL pathname (the host is ignored — VTEX
38
+ * spreads the same API surface across `*.vtexcommercestable.*`,
39
+ * `*.myvtex.com`, and storefront origins, all on identical paths).
40
+ *
41
+ * Operation strings are bare (no `vtex.` prefix) — the framework
42
+ * prefixes them with the integration name at span time.
43
+ */
44
+ const MATCHERS: ReadonlyArray<Matcher> = [
45
+ m(/^\/api\/io\/_v\/api\/intelligent-search\/([a-z_-]+)/, (mm) => `intelligent-search.${mm[1]}`),
46
+ m(/^\/_v\/private\/graphql\/v1/, "io.graphql"),
47
+ m(/^\/_v\/segment\//, "io.segment"),
48
+
49
+ m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/items\/update/, (_mm, method) =>
50
+ method === "POST" ? "checkout.orderform.items.update" : "checkout.orderform.items",
51
+ ),
52
+ m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/items/, (_mm, method) => {
53
+ if (method === "DELETE") return "checkout.orderform.items.remove";
54
+ if (method === "PATCH" || method === "PUT") return "checkout.orderform.items.update";
55
+ return "checkout.orderform.items.add";
56
+ }),
57
+ m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/coupons/, "checkout.orderform.coupons"),
58
+ m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/profile/, "checkout.orderform.profile"),
59
+ m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/shippingData/, "checkout.orderform.shipping"),
60
+ m(/^\/api\/checkout\/pub\/orderForm\/[^/]+\/paymentData/, "checkout.orderform.payment"),
61
+ m(/^\/api\/checkout\/pub\/orderForm\/[^/]+/, (_mm, method) =>
62
+ method === "GET" ? "checkout.orderform.get" : "checkout.orderform.update",
63
+ ),
64
+ m(/^\/api\/checkout\/pub\/orderForm(?:\/?$)/, (_mm, method) =>
65
+ method === "POST" ? "checkout.orderform.create" : "checkout.orderform.get",
66
+ ),
67
+ m(/^\/api\/checkout\/pub\/orderForms\/simulation/, "checkout.simulation"),
68
+ m(/^\/api\/checkout\/pub\/regions/, "checkout.regions"),
69
+ m(/^\/api\/checkout\/pub\/postal-code/, "checkout.postal-code"),
70
+
71
+ m(/^\/api\/sessions/, (_mm, method) => (method === "POST" ? "sessions.update" : "sessions.get")),
72
+ m(/^\/api\/segments\//, "segments.get"),
73
+
74
+ m(/^\/api\/catalog_system\/pub\/portal\/pagetype\//, "catalog.pagetype"),
75
+ m(
76
+ /^\/api\/catalog_system\/pub\/products\/crossselling\/([^/]+)/,
77
+ (mm) => `catalog.crossselling.${mm[1]}`,
78
+ ),
79
+ m(/^\/api\/catalog_system\/pub\/products\/variations\//, "catalog.products.variations"),
80
+ m(/^\/api\/catalog_system\/pub\/products\/search/, "catalog.products.search"),
81
+ m(/^\/api\/catalog_system\/pub\/facets\/search/, "catalog.facets.search"),
82
+ m(/^\/api\/catalog_system\/pub\/category\/tree/, "catalog.category.tree"),
83
+ m(/^\/api\/catalog_system\/(?:pub|pvt)\/specification/, "catalog.specification"),
84
+ m(/^\/api\/catalog_system\/pub\/brand/, "catalog.brand"),
85
+ m(/^\/api\/catalog_system\/pvt\/sku\//, "catalog.sku"),
86
+ m(/^\/api\/catalog_system\//, "catalog.other"),
87
+
88
+ m(/^\/api\/wishlist\//, "wishlist"),
89
+ m(/^\/api\/profile-system\/profile\//, "profile"),
90
+ m(/^\/api\/dataentities\/([^/]+)/, (mm) => `masterdata.${mm[1]}`),
91
+
92
+ m(/^\/api\/oms\/user\/orders\/[^/]+\/cancel/, "oms.orders.cancel"),
93
+ m(/^\/api\/oms\/user\/orders/, "oms.orders"),
94
+ m(/^\/api\/oms\/pvt\/orders/, "oms.orders.pvt"),
95
+
96
+ m(/^\/api\/vtexid\/pub\/logout/, "vtexid.logout"),
97
+ m(/^\/api\/vtexid\/pub\/authentication\/start/, "vtexid.authentication.start"),
98
+ m(/^\/api\/vtexid\/pub\/authentication\/[a-z]+\/validate/, "vtexid.authentication.validate"),
99
+ m(/^\/api\/vtexid\/pub\/authenticated\/user/, "vtexid.user"),
100
+ m(/^\/api\/vtexid\//, "vtexid.other"),
101
+
102
+ m(/^\/api\/events\/v1\//, "events.send"),
103
+ m(/^\/sitemap.*\.xml$/, "sitemap"),
104
+ m(/^\/api\/license-manager/, "license-manager"),
105
+ ];
106
+
107
+ /**
108
+ * Resolve an operation name for a VTEX URL. Returns `undefined` if no
109
+ * matcher fires, which causes the framework to fall back to
110
+ * `vtex.fetch`.
111
+ *
112
+ * Designed to be passed directly to `createInstrumentedFetch`:
113
+ *
114
+ * ```ts
115
+ * createInstrumentedFetch({
116
+ * name: "vtex",
117
+ * resolveOperation: vtexOperationRouter,
118
+ * });
119
+ * ```
120
+ */
121
+ export function vtexOperationRouter(url: string, method: string): string | undefined {
122
+ let pathname: string;
123
+ try {
124
+ pathname = new URL(url).pathname;
125
+ } catch {
126
+ const qs = url.indexOf("?");
127
+ const hash = url.indexOf("#");
128
+ const end = [qs, hash].filter((i) => i >= 0).sort((a, b) => a - b)[0];
129
+ pathname = end === undefined ? url : url.slice(0, end);
130
+ }
131
+
132
+ const upperMethod = method.toUpperCase();
133
+ for (const { pattern, operation } of MATCHERS) {
134
+ const match = pathname.match(pattern);
135
+ if (!match) continue;
136
+ return typeof operation === "function" ? operation(match, upperMethod) : operation;
137
+ }
138
+ return undefined;
139
+ }
@@ -14,7 +14,7 @@
14
14
  * fetch handlers.
15
15
  */
16
16
 
17
- import { getVtexConfig, type VtexConfig, vtexHost } from "../client";
17
+ import { getVtexConfig, getVtexFetch, type VtexConfig, vtexHost } from "../client";
18
18
  import { proxySetCookie } from "./cookies";
19
19
 
20
20
  export interface VtexProxyOptions {
@@ -173,7 +173,12 @@ export async function proxyToVtex(request: Request, options?: VtexProxyOptions):
173
173
  init.duplex = "half";
174
174
  }
175
175
 
176
- const originResponse = await fetch(originUrl.toString(), init);
176
+ // Route through the configured VTEX fetch so traces / metrics / logs
177
+ // see the proxied origin call. The URL router classifies the call
178
+ // into the right `vtex.<area>.<op>` bucket (e.g. `vtex.checkout.*`,
179
+ // `vtex.vtexid.logout`, `vtex.io.segment`) — no per-callsite hint
180
+ // needed because we're a generic forwarder.
181
+ const originResponse = await getVtexFetch()(originUrl.toString(), init);
177
182
 
178
183
  const responseHeaders = filterHeaders(new Headers(originResponse.headers));
179
184
 
@@ -380,7 +385,7 @@ export function createVtexCheckoutProxy(
380
385
  init.duplex = "half";
381
386
  }
382
387
 
383
- const originRes = await fetch(originUrl.toString(), init);
388
+ const originRes = await getVtexFetch()(originUrl.toString(), init);
384
389
  const resHeaders = filterHeadersStrict(new Headers(originRes.headers));
385
390
  rewriteSetCookieDomain(originRes.headers, resHeaders, url.hostname);
386
391