From a816176a7428f72e6784b30988c73e8327ce4dba Mon Sep 17 00:00:00 2001 From: Nicacio Oliveira Date: Thu, 11 Jun 2026 11:54:45 -0300 Subject: [PATCH 1/4] feat(observability)!: emit canonical http.client.request.duration via framework helper Replace the direct meter call to the legacy snake_case histogram name with the framework's `recordCommerceMetric(...)` helper. The framework already emits canonical OTel SemConv names everywhere else; the VTEX and Shopify factories were the only producers still hardcoding the old name, splitting outbound-fetch samples between two metrics in the data lake. Also bump the @decocms/start peer dep min to >=6.6.0 (the version that exports `recordCommerceMetric` and finalised the canonical names). BREAKING CHANGE: the outbound commerce histogram is now emitted as `http.client.request.duration` (canonical) instead of `commerce_request_duration_ms`. Status is reported as `status_class` (2xx/3xx/4xx/5xx) instead of the full `status_code`. `cached` is now a boolean attribute. Dashboards / alerts querying the old metric name or labels need to be updated. Peer dep on `@decocms/start` is now `>=6.6.0`. Co-Authored-By: Claude Opus 4.7 --- README.md | 7 +++--- package.json | 4 +-- .../utils/__tests__/instrumentedFetch.test.ts | 5 ++-- shopify/utils/instrumentedFetch.ts | 16 ++++++------ vtex/client.ts | 2 +- .../utils/__tests__/instrumentedFetch.test.ts | 19 +++++++------- vtex/utils/instrumentedFetch.ts | 25 ++++++++----------- 7 files changed, 38 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 266a6cc..d9b73da 100644 --- a/README.md +++ b/README.md @@ -73,9 +73,10 @@ createSiteSetup({ }); // 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`). +// canonical `http.client.request.duration` histogram into every +// outbound VTEX call (via `recordCommerceMetric`). Operation names +// are derived from the URL via `vtexOperationRouter` (overridable +// per call via `init.operation`). setVtexFetch(createVtexFetch()); ``` diff --git a/package.json b/package.json index d96a4af..d5f74a5 100644 --- a/package.json +++ b/package.json @@ -135,7 +135,7 @@ "access": "public" }, "peerDependencies": { - "@decocms/start": ">=5.3.0", + "@decocms/start": ">=6.6.0", "@tanstack/react-query": ">=5", "@tanstack/react-start": ">=1", "algoliasearch": "^5", @@ -149,7 +149,7 @@ }, "devDependencies": { "@biomejs/biome": "^2.4.7", - "@decocms/start": "5.3.0", + "@decocms/start": "6.6.1", "@semantic-release/exec": "^7.1.0", "@tanstack/react-query": "^5.90.21", "@types/react": "^19.0.0", diff --git a/shopify/utils/__tests__/instrumentedFetch.test.ts b/shopify/utils/__tests__/instrumentedFetch.test.ts index 133d617..a0bfca6 100644 --- a/shopify/utils/__tests__/instrumentedFetch.test.ts +++ b/shopify/utils/__tests__/instrumentedFetch.test.ts @@ -34,7 +34,7 @@ describe("createShopifyFetch", () => { }); }); - it("emits commerce_request_duration_ms with provider=shopify on success", async () => { + it("emits http.client.request.duration with provider=shopify on success", async () => { const { calls, meter } = captureHistogram(); configureMeter(meter); @@ -44,10 +44,11 @@ describe("createShopifyFetch", () => { await fetchFn("https://store.myshopify.com/api/2025-04/graphql.json", { method: "POST" }); expect(calls).toHaveLength(1); + expect(calls[0].name).toBe("http.client.request.duration"); expect(calls[0].attrs).toMatchObject({ provider: "shopify", operation: "storefront.graphql", - status_code: "200", + status_class: "2xx", }); }); diff --git a/shopify/utils/instrumentedFetch.ts b/shopify/utils/instrumentedFetch.ts index 8456690..e027ffd 100644 --- a/shopify/utils/instrumentedFetch.ts +++ b/shopify/utils/instrumentedFetch.ts @@ -7,8 +7,9 @@ * 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"`. + * 3. An `onComplete` that records the canonical + * `http.client.request.duration` histogram (via the framework's + * `recordCommerceMetric(...)` helper) with `provider: "shopify"`. * * Sites do: * @@ -26,11 +27,9 @@ import { createInstrumentedFetch, type InstrumentedFetch, } from "@decocms/start/sdk/instrumentedFetch"; -import { getMeter } from "@decocms/start/sdk/observability"; +import { recordCommerceMetric } from "@decocms/start/sdk/observability"; import { shopifyOperationRouter } from "./operationRouter"; -const HISTOGRAM_NAME = "commerce_request_duration_ms"; - export interface CreateShopifyFetchOptions { baseFetch?: typeof fetch; disableHistogram?: boolean; @@ -45,12 +44,11 @@ export function createShopifyFetch(options: CreateShopifyFetchOptions = {}): Ins onComplete: disableHistogram ? undefined : ({ operation, status, durationMs, cached }) => { - const meter = getMeter(); - meter?.histogramRecord?.(HISTOGRAM_NAME, durationMs, { + recordCommerceMetric(durationMs, { provider: "shopify", operation, - status_code: String(status), - cached: cached ? "true" : "false", + status_class: `${Math.floor(status / 100)}xx`, + cached, }); }, }); diff --git a/vtex/client.ts b/vtex/client.ts index e127b93..dde91a5 100644 --- a/vtex/client.ts +++ b/vtex/client.ts @@ -157,7 +157,7 @@ export function configureVtex(config: VtexConfig) { /** * Override the fetch function used by all VTEX client calls. * Pass an `InstrumentedFetch` to get spans, traceparent injection, - * URL redaction, and the `commerce_request_duration_ms` histogram — + * URL redaction, and the canonical `http.client.request.duration` histogram — * use the pre-wired `createVtexFetch()` factory: * * ```ts diff --git a/vtex/utils/__tests__/instrumentedFetch.test.ts b/vtex/utils/__tests__/instrumentedFetch.test.ts index 78ede28..17b0d3b 100644 --- a/vtex/utils/__tests__/instrumentedFetch.test.ts +++ b/vtex/utils/__tests__/instrumentedFetch.test.ts @@ -6,8 +6,9 @@ * * - 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. + * - The canonical `http.client.request.duration` histogram is recorded + * with the right labels on every call (via the framework's + * `recordCommerceMetric` helper). * - `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). @@ -50,7 +51,7 @@ describe("createVtexFetch", () => { }); }); - it("records commerce_request_duration_ms with provider/operation/status labels on success", async () => { + it("records http.client.request.duration with provider/operation/status labels on success", async () => { const { calls, meter } = captureHistogram(); configureMeter(meter); @@ -60,12 +61,12 @@ describe("createVtexFetch", () => { 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].name).toBe("http.client.request.duration"); expect(calls[0].attrs).toMatchObject({ provider: "vtex", operation: "sessions.get", - status_code: "200", - cached: "false", + status_class: "2xx", + cached: false, }); expect(calls[0].value).toBeGreaterThanOrEqual(0); }); @@ -109,10 +110,10 @@ describe("createVtexFetch", () => { await fetchFn("https://store.vtexcommercestable.com.br/api/sessions"); - expect(calls[0].attrs.cached).toBe("true"); + expect(calls[0].attrs.cached).toBe(true); }); - it("emits status_code reflecting the actual response status", async () => { + it("emits status_class derived from the actual response status", async () => { const { calls, meter } = captureHistogram(); configureMeter(meter); @@ -121,7 +122,7 @@ describe("createVtexFetch", () => { await fetchFn("https://store.vtexcommercestable.com.br/api/sessions"); - expect(calls[0].attrs.status_code).toBe("503"); + expect(calls[0].attrs.status_class).toBe("5xx"); }); it("skips histogram emission when disableHistogram is true", async () => { diff --git a/vtex/utils/instrumentedFetch.ts b/vtex/utils/instrumentedFetch.ts index c93f9f3..386548d 100644 --- a/vtex/utils/instrumentedFetch.ts +++ b/vtex/utils/instrumentedFetch.ts @@ -10,10 +10,10 @@ * 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 + * canonical `http.client.request.duration` histogram via the + * framework's `recordCommerceMetric(...)` helper in * `@decocms/start/sdk/observability` — `provider`, `operation`, - * `status_code`, and `cached` labels. + * `status_class`, and `cached` labels. * * Sites opt in once at startup: * @@ -34,11 +34,9 @@ import { createInstrumentedFetch, type InstrumentedFetch, } from "@decocms/start/sdk/instrumentedFetch"; -import { getMeter } from "@decocms/start/sdk/observability"; +import { recordCommerceMetric } 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`. @@ -48,10 +46,10 @@ export interface CreateVtexFetchOptions { */ 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. + * Disable the `http.client.request.duration` histogram emission for + * VTEX calls. 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; } @@ -69,12 +67,11 @@ export function createVtexFetch(options: CreateVtexFetchOptions = {}): Instrumen onComplete: disableHistogram ? undefined : ({ operation, status, durationMs, cached }) => { - const meter = getMeter(); - meter?.histogramRecord?.(HISTOGRAM_NAME, durationMs, { + recordCommerceMetric(durationMs, { provider: "vtex", operation, - status_code: String(status), - cached: cached ? "true" : "false", + status_class: `${Math.floor(status / 100)}xx`, + cached, }); }, }); From afa2400646f86e02758b7043df4ea996f52f3f81 Mon Sep 17 00:00:00 2001 From: Nicacio Oliveira Date: Thu, 11 Jun 2026 13:50:10 -0300 Subject: [PATCH 2/4] chore(deps): regenerate bun.lock for @decocms/start 6.6.1 CI runs `bun install --frozen-lockfile`; the package.json bump in this PR wasn't reflected in bun.lock yet. Co-Authored-By: Claude Opus 4.7 --- bun.lock | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/bun.lock b/bun.lock index e50d452..655f75d 100644 --- a/bun.lock +++ b/bun.lock @@ -6,7 +6,7 @@ "name": "@decocms/apps", "devDependencies": { "@biomejs/biome": "^2.4.7", - "@decocms/start": "5.3.0", + "@decocms/start": "6.6.1", "@semantic-release/exec": "^7.1.0", "@tanstack/react-query": "^5.90.21", "@types/react": "^19.0.0", @@ -21,8 +21,9 @@ "vitest": "^4.1.0", }, "peerDependencies": { - "@decocms/start": ">=5.3.0", + "@decocms/start": ">=6.6.0", "@tanstack/react-query": ">=5", + "@tanstack/react-start": ">=1", "algoliasearch": "^5", "react": ">=18", "react-dom": ">=18", @@ -131,7 +132,7 @@ "@deco-cx/warp-node": ["@deco-cx/warp-node@0.3.20", "", { "dependencies": { "undici": "^6.21.0", "ws": "^8.18.0" } }, "sha512-rdRWrT5eMhu1zhAzliRkoQCUr2j6Dg9npUKoP4uP+rV9wIbYKSmXJbM2z/fOiy5FVvzQlpvY16ACNRIRz+UWqw=="], - "@decocms/start": ["@decocms/start@5.3.0", "", { "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" }, "peerDependencies": { "@tanstack/react-query": ">=5.0.0", "@tanstack/react-start": ">=1.0.0", "@tanstack/store": ">=0.7.0", "react": "^19.0.0", "react-dom": "^19.0.0", "vite": ">=6.0.0 || >=7.0.0 || >=8.0.0" }, "bin": { "deco-migrate": "scripts/migrate.ts", "deco-post-cleanup": "scripts/migrate-post-cleanup.ts", "deco-htmx-analyze": "scripts/htmx-analyze.ts", "deco-cf-observability": "scripts/migrate-to-cf-observability.ts", "deco-audit-observability": "scripts/audit-observability-config.ts" } }, "sha512-98FH8wHClYDHZQDtnaKWTA2AIn5gKl29sIIqEDAB4MqHX0FbV5fHsetG0aYKjdWBUrxln/GZ9nzckgibdUVz9w=="], + "@decocms/start": ["@decocms/start@6.6.1", "", { "dependencies": { "@deco-cx/warp-node": "^0.3.16", "@opentelemetry/api": "^1.9.1", "@opentelemetry/semantic-conventions": "^1.28.0", "clsx": "^2.1.1", "fast-json-patch": "^3.1.0", "tailwind-merge": "^3.3.1", "tsx": "^4.19.0", "ws": "^8.18.0" }, "peerDependencies": { "@tanstack/react-query": ">=5.0.0", "@tanstack/react-start": ">=1.0.0", "@tanstack/store": ">=0.7.0", "react": "^19.0.0", "react-dom": "^19.0.0", "vite": ">=6.0.0 || >=7.0.0 || >=8.0.0" }, "bin": { "deco-migrate": "scripts/migrate.ts", "deco-post-cleanup": "scripts/migrate-post-cleanup.ts", "deco-htmx-analyze": "scripts/htmx-analyze.ts", "deco-cf-observability": "scripts/migrate-to-cf-observability.ts", "deco-audit-observability": "scripts/audit-observability-config.ts" } }, "sha512-nBCJQehm8yJ2eTkiSkfrTZ0yYAJ6qGbxSB/8oSpEQaeAfGiRSplOlhu7EE5WljMkbXh8o3fkLWVRNJI1qwik9w=="], "@emnapi/core": ["@emnapi/core@1.10.0", "", { "dependencies": { "@emnapi/wasi-threads": "1.2.1", "tslib": "^2.4.0" } }, "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw=="], @@ -241,6 +242,8 @@ "@opentelemetry/api": ["@opentelemetry/api@1.9.1", "", {}, "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q=="], + "@opentelemetry/semantic-conventions": ["@opentelemetry/semantic-conventions@1.41.1", "", {}, "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA=="], + "@oxc-project/types": ["@oxc-project/types@0.130.0", "", {}, "sha512-ibD2usx9JRu7f5pu2tMKMI4cpA4NgXJQoYRP4pQ7Pxmn1l6k/53qWtQWZayhYy3X4QZkt90Ot+mJEaeXouio6Q=="], "@oxc-resolver/binding-android-arm-eabi": ["@oxc-resolver/binding-android-arm-eabi@11.19.1", "", { "os": "android", "cpu": "arm" }, "sha512-aUs47y+xyXHUKlbhqHUjBABjvycq6YSD7bpxSW7vplUmdzAlJ93yXY6ZR0c1o1x5A/QKbENCvs3+NlY8IpIVzg=="], From 31d7c469fd2d67b89d2fd7505049dff43bb777d9 Mon Sep 17 00:00:00 2001 From: Nicacio Oliveira Date: Thu, 11 Jun 2026 14:08:11 -0300 Subject: [PATCH 3/4] chore(blog): auto-fix organizeImports in blog tests to unblock CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing lint errors in blog/__tests__/handlePosts.test.ts and blog/__tests__/loaders.test.ts surfaced on this PR because CI only runs on pull requests, not on direct pushes to main. Mechanical fix from `biome check --write` — no behavior change. Co-Authored-By: Claude Opus 4.7 --- blog/__tests__/handlePosts.test.ts | 2 +- blog/__tests__/loaders.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/blog/__tests__/handlePosts.test.ts b/blog/__tests__/handlePosts.test.ts index 6043aff..808eb13 100644 --- a/blog/__tests__/handlePosts.test.ts +++ b/blog/__tests__/handlePosts.test.ts @@ -1,4 +1,3 @@ -import type { BlogPost, SortBy } from "../types"; import handlePosts, { filterPostsByCategory, filterPostsBySlugs, @@ -7,6 +6,7 @@ import handlePosts, { slicePosts, sortPosts, } from "../core/handlePosts"; +import type { BlogPost, SortBy } from "../types"; function makePost(overrides: Partial = {}): BlogPost { return { diff --git a/blog/__tests__/loaders.test.ts b/blog/__tests__/loaders.test.ts index ca9ebf4..9e480f4 100644 --- a/blog/__tests__/loaders.test.ts +++ b/blog/__tests__/loaders.test.ts @@ -5,12 +5,12 @@ vi.mock("../core/records", () => ({ })); import { getRecordsByPath } from "../core/records"; -import type { BlogPost, Category } from "../types"; +import BlogPostItem from "../loaders/BlogPostItem"; +import BlogPostPageLoader from "../loaders/BlogPostPage"; import BlogpostListing from "../loaders/BlogpostListing"; import BlogRelatedPostsLoader from "../loaders/BlogRelatedPosts"; -import BlogPostPageLoader from "../loaders/BlogPostPage"; import GetCategories from "../loaders/GetCategories"; -import BlogPostItem from "../loaders/BlogPostItem"; +import type { BlogPost, Category } from "../types"; const mockGetRecords = getRecordsByPath as ReturnType; From 81bd6d105198ec1945a54ed97b61234fb53f7286 Mon Sep 17 00:00:00 2001 From: Nicacio Oliveira Date: Thu, 11 Jun 2026 14:14:06 -0300 Subject: [PATCH 4/4] chore(blog): add vitest imports + replace Array.toSorted to unblock typecheck MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pre-existing typecheck errors in blog/ surfaced on CI because the workflow only runs on pull requests, not pushes to main. Three classes of fix: - Add explicit `vitest` imports (`describe`/`it`/`expect`/`beforeEach`) to every `blog/__tests__/*.test.ts` — matches the convention used by all other test files in the repo. - Replace `blogPosts.toSorted(...)` with `[...blogPosts].sort(...)` in `blog/core/handlePosts.ts` — same non-destructive semantics, doesn't require bumping the tsconfig `lib` to ES2023. - Tighten the `ResolveSecretFn` stub in `mod.test.ts` to return `null` instead of `undefined` to satisfy `Promise`. Co-Authored-By: Claude Opus 4.7 --- blog/__tests__/handlePosts.test.ts | 1 + blog/__tests__/loaders.test.ts | 2 +- blog/__tests__/mod.test.ts | 3 ++- blog/__tests__/records.test.ts | 2 +- blog/core/handlePosts.ts | 2 +- 5 files changed, 6 insertions(+), 4 deletions(-) diff --git a/blog/__tests__/handlePosts.test.ts b/blog/__tests__/handlePosts.test.ts index 808eb13..d445648 100644 --- a/blog/__tests__/handlePosts.test.ts +++ b/blog/__tests__/handlePosts.test.ts @@ -1,3 +1,4 @@ +import { describe, expect, it } from "vitest"; import handlePosts, { filterPostsByCategory, filterPostsBySlugs, diff --git a/blog/__tests__/loaders.test.ts b/blog/__tests__/loaders.test.ts index 9e480f4..14d83a5 100644 --- a/blog/__tests__/loaders.test.ts +++ b/blog/__tests__/loaders.test.ts @@ -1,4 +1,4 @@ -import { vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("../core/records", () => ({ getRecordsByPath: vi.fn(), diff --git a/blog/__tests__/mod.test.ts b/blog/__tests__/mod.test.ts index 5eed596..ded33ec 100644 --- a/blog/__tests__/mod.test.ts +++ b/blog/__tests__/mod.test.ts @@ -1,8 +1,9 @@ +import { describe, expect, it } from "vitest"; import { configure } from "../mod"; describe("blog module", () => { it("returns AppDefinition with name 'blog' and manifest", async () => { - const app = await configure({}, async () => undefined); + const app = await configure({}, async () => null); expect(app.name).toBe("blog"); expect(app.manifest).toBeDefined(); expect(app.state).toEqual({}); diff --git a/blog/__tests__/records.test.ts b/blog/__tests__/records.test.ts index 416f648..351e850 100644 --- a/blog/__tests__/records.test.ts +++ b/blog/__tests__/records.test.ts @@ -1,4 +1,4 @@ -import { vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; vi.mock("@decocms/start/cms", () => ({ loadBlocks: vi.fn(), diff --git a/blog/core/handlePosts.ts b/blog/core/handlePosts.ts index 2e2faa9..8e79e74 100644 --- a/blog/core/handlePosts.ts +++ b/blog/core/handlePosts.ts @@ -11,7 +11,7 @@ export const sortPosts = (blogPosts: BlogPost[], sortBy: SortBy): BlogPost[] => const sortMethod = (parts[0] in blogPosts[0] ? parts[0] : "date") as keyof BlogPost; const sortOrder = VALID_SORT_ORDERS.includes(parts[1]) ? parts[1] : "desc"; - return blogPosts.toSorted((a, b) => { + return [...blogPosts].sort((a, b) => { if (!a[sortMethod] && !b[sortMethod]) return 0; if (!a[sortMethod]) return 1; if (!b[sortMethod]) return -1;