@decocms/start 6.0.1 → 6.2.0

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.
@@ -15,27 +15,45 @@
15
15
  *
16
16
  * Rules (id — severity — what it catches):
17
17
  *
18
- * observability_missing error No `observability` key at all. CF captures nothing.
19
- * observability_disabled error `observability.enabled: false`. Master switch off.
20
- * traces_disabled warn `observability.traces.enabled: false`. No traces in dashboard.
21
- * logs_disabled warn `observability.logs.enabled: false`. No logs in dashboard.
22
- * head_sampling_rate_elevated error `traces.head_sampling_rate > 0.01`. Fleet-scale cost risk; see docs/observability.md.
23
- * logs_head_sampling_rate_low warn `logs.head_sampling_rate < 1`. Sampling info/warn logs loses signal cheaply; errors go via the direct-POST channel.
24
- * persist_disabled_no_destination error `persist: false` with no destination configured. Data captured then discarded.
18
+ * observability_missing error No `observability` key at all. CF captures nothing.
19
+ * observability_disabled error `observability.enabled: false`. Master switch off.
20
+ * traces_disabled warn `observability.traces.enabled: false`. No traces in dashboard.
21
+ * logs_disabled warn `observability.logs.enabled: false`. No logs in dashboard.
22
+ * head_sampling_rate_elevated error `traces.head_sampling_rate > 0.01`. Fleet-scale cost risk; see docs/observability.md.
23
+ * logs_head_sampling_rate_low warn `logs.head_sampling_rate < 1`. Sampling info/warn logs loses signal cheaply; errors go via the direct-POST channel.
24
+ * persist_disabled_no_destination error `persist: false` with no destination configured. Data captured then discarded.
25
+ *
26
+ * Phase 6 / D-14 — fleet-config drift rules (live outside the
27
+ * `observability` block but still owned by this audit):
28
+ *
29
+ * version_metadata_binding_missing error Missing `version_metadata` binding. `service.version` won't be stamped — regressions can't be attributed to a deploy.
30
+ * analytics_engine_binding_missing warn No `DECO_METRICS` AE binding. AE meter is off; OTLP meter still works but CF dashboard panels go dark.
31
+ * tail_consumer_missing error No `tail_consumers` entry pointing at `deco-otel-tail`. 100% error-capture is broken.
32
+ * otel_metrics_endpoint_missing warn `DECO_OTEL_METRICS_ENDPOINT` not set on `vars`. OTLP meter is off; only AE works.
33
+ * otel_traces_endpoint_missing warn `DECO_OTEL_TRACES_ENDPOINT` not set on `vars`. Framework `deco.*` spans drop unless CF Traces is on.
34
+ * otel_logs_endpoint_missing warn `DECO_OTEL_LOGS_ENDPOINT` not set on `vars`. Error logs ride CF Destinations only (head-sampled).
25
35
  *
26
36
  * Usage (from a site directory):
27
- * npx -p @decocms/start deco-audit-observability # audit cwd
28
- * npx -p @decocms/start deco-audit-observability --source ./ # explicit
37
+ * npx -p @decocms/start deco-audit-observability # audit cwd (warn mode — exit 0)
38
+ * npx -p @decocms/start deco-audit-observability --source ./ # explicit source dir
29
39
  * npx -p @decocms/start deco-audit-observability --json # machine-readable
40
+ * npx -p @decocms/start deco-audit-observability --mode block # error findings exit 1 (CI gate)
41
+ * npx -p @decocms/start deco-audit-observability --github # GitHub Actions annotations
30
42
  *
31
43
  * Options:
32
44
  * --source <dir> Site directory (default: .)
33
- * --json Emit findings as JSON to stdout (still exits non-zero on findings)
45
+ * --json Emit findings as JSON to stdout
46
+ * --mode <m> Gate hardness: "warn" (default — always exit 0 on findings,
47
+ * just print them) or "block" (exit 1 on any `error` finding).
48
+ * See D-16 in MIGRATION_TOOLING_PLAN.md for the rationale on
49
+ * why warn is the v1 default.
50
+ * --github Emit `::warning::` / `::error::` lines for GitHub Actions
51
+ * annotations in addition to the normal text output.
34
52
  * --help, -h Show this message
35
53
  *
36
54
  * Exit codes:
37
- * 0 — no findings (or only `info`-level findings; none defined yet)
38
- * 1 — at least one finding (warn or error)
55
+ * 0 — no findings, or `--mode warn` (the default) regardless of findings
56
+ * 1 — `--mode block` and at least one `error`-severity finding
39
57
  * 2 — file invalid / can't parse
40
58
  */
41
59
 
@@ -53,14 +71,24 @@ export interface Finding {
53
71
  fix?: string;
54
72
  }
55
73
 
74
+ export type GateMode = "warn" | "block";
75
+
56
76
  interface CliOpts {
57
77
  source: string;
58
78
  json: boolean;
59
79
  help: boolean;
80
+ mode: GateMode;
81
+ github: boolean;
60
82
  }
61
83
 
62
84
  function parseArgs(argv: string[]): CliOpts {
63
- const opts: CliOpts = { source: ".", json: false, help: false };
85
+ const opts: CliOpts = {
86
+ source: ".",
87
+ json: false,
88
+ help: false,
89
+ mode: "warn",
90
+ github: false,
91
+ };
64
92
  for (let i = 0; i < argv.length; i++) {
65
93
  const flag = argv[i];
66
94
  switch (flag) {
@@ -70,6 +98,20 @@ function parseArgs(argv: string[]): CliOpts {
70
98
  case "--json":
71
99
  opts.json = true;
72
100
  break;
101
+ case "--mode": {
102
+ const value = argv[++i];
103
+ if (value !== "warn" && value !== "block") {
104
+ console.error(
105
+ `audit: --mode must be "warn" or "block" (got "${value ?? ""}")`,
106
+ );
107
+ process.exit(2);
108
+ }
109
+ opts.mode = value;
110
+ break;
111
+ }
112
+ case "--github":
113
+ opts.github = true;
114
+ break;
73
115
  case "--help":
74
116
  case "-h":
75
117
  opts.help = true;
@@ -91,14 +133,18 @@ function showHelp(): void {
91
133
  npx -p @decocms/start deco-audit-observability [options]
92
134
 
93
135
  Options:
94
- --source <dir> Site directory (default: .)
95
- --json Emit findings as JSON
96
- --help, -h This message
136
+ --source <dir> Site directory (default: .)
137
+ --json Emit findings as JSON
138
+ --mode <m> "warn" (default, exit 0) | "block" (exit 1 on errors)
139
+ --github Emit ::warning::/::error:: lines for GitHub Actions
140
+ --help, -h This message
97
141
 
98
142
  Exit codes:
99
- 0 no findings
100
- 1 one or more findings (warn or error)
143
+ 0 no findings, OR --mode warn (default — annotate and move on)
144
+ 1 --mode block AND at least one error-severity finding
101
145
  2 wrangler.jsonc missing or unparseable
146
+
147
+ See D-16 in MIGRATION_TOOLING_PLAN.md for the v1 "warn-only" policy.
102
148
  `);
103
149
  }
104
150
 
@@ -236,6 +282,138 @@ export function auditObservabilityBlock(
236
282
  return findings;
237
283
  }
238
284
 
285
+ /**
286
+ * Fleet-config drift rules — owned by the same audit because the
287
+ * cumulative effect of "observability block correct, bindings missing"
288
+ * is identical to "observability block missing" (no data lands in
289
+ * ClickHouse).
290
+ *
291
+ * The CLI composes `auditObservabilityBlock` + `auditFleetBindings`.
292
+ * Both return Finding[]; callers concatenate.
293
+ */
294
+ export interface WranglerLike {
295
+ observability?: ObservabilityBlock;
296
+ version_metadata?: { binding?: string } | unknown;
297
+ analytics_engine_datasets?: Array<{ binding?: string; dataset?: string }> | unknown;
298
+ tail_consumers?: Array<{ service?: string; environment?: string }> | unknown;
299
+ vars?: Record<string, unknown> | unknown;
300
+ }
301
+
302
+ export function auditFleetBindings(wrangler: WranglerLike): Finding[] {
303
+ const findings: Finding[] = [];
304
+
305
+ // version_metadata — required so `service.version` is stamped on every
306
+ // span and log line. Without it, regressions can't be tied to a
307
+ // specific deployment.
308
+ const vmBinding =
309
+ typeof wrangler.version_metadata === "object" &&
310
+ wrangler.version_metadata !== null &&
311
+ "binding" in wrangler.version_metadata
312
+ ? (wrangler.version_metadata as { binding?: string }).binding
313
+ : undefined;
314
+ if (!vmBinding) {
315
+ findings.push({
316
+ id: "version_metadata_binding_missing",
317
+ severity: "error",
318
+ message:
319
+ "wrangler.jsonc is missing a `version_metadata.binding` entry. " +
320
+ "`service.version` won't appear on spans/logs and the deploy-correlation " +
321
+ "panel will be empty. Recommended value: `CF_VERSION_METADATA`.",
322
+ fix: "npx -p @decocms/start deco-cf-observability --write",
323
+ });
324
+ }
325
+
326
+ // DECO_METRICS — Analytics Engine binding. The AE meter is the hot-
327
+ // path CF dashboard view; OTLP works without it but we lose the
328
+ // operator-grade short-window panels.
329
+ const aeDatasets = Array.isArray(wrangler.analytics_engine_datasets)
330
+ ? (wrangler.analytics_engine_datasets as Array<{ binding?: string }>)
331
+ : [];
332
+ const hasMetricsBinding = aeDatasets.some((d) => d?.binding === "DECO_METRICS");
333
+ if (!hasMetricsBinding) {
334
+ findings.push({
335
+ id: "analytics_engine_binding_missing",
336
+ severity: "warn",
337
+ message:
338
+ "wrangler.jsonc has no `analytics_engine_datasets[].binding == 'DECO_METRICS'`. " +
339
+ "The AE meter is off; the hot-path operator dashboards in CF will be empty. " +
340
+ "OTLP metrics keep flowing if `DECO_OTEL_METRICS_ENDPOINT` is set.",
341
+ fix: "npx -p @decocms/start deco-cf-observability --write",
342
+ });
343
+ }
344
+
345
+ // tail_consumers — must list deco-otel-tail. Phase 1 enrichment is
346
+ // useless without the tail consumer firing.
347
+ const tail = Array.isArray(wrangler.tail_consumers)
348
+ ? (wrangler.tail_consumers as Array<{ service?: string }>)
349
+ : [];
350
+ const hasTailConsumer = tail.some((t) => t?.service === "deco-otel-tail");
351
+ if (!hasTailConsumer) {
352
+ findings.push({
353
+ id: "tail_consumer_missing",
354
+ severity: "error",
355
+ message:
356
+ "wrangler.jsonc has no `tail_consumers[].service == 'deco-otel-tail'` entry. " +
357
+ "100% error-capture is broken — only the head-sampled CF Destinations path " +
358
+ "will report errors, and isolate crashes will be invisible.",
359
+ fix: "npx -p @decocms/start deco-cf-observability --write",
360
+ });
361
+ }
362
+
363
+ // DECO_OTEL_*_ENDPOINT env vars. Audit each separately so the message
364
+ // explains which channel is silently no-op.
365
+ const vars =
366
+ typeof wrangler.vars === "object" && wrangler.vars !== null
367
+ ? (wrangler.vars as Record<string, unknown>)
368
+ : {};
369
+ const checkVar = (id: string, name: string, severity: Severity, channel: string) => {
370
+ const v = vars[name];
371
+ if (typeof v !== "string" || v.length === 0) {
372
+ findings.push({
373
+ id,
374
+ severity,
375
+ message:
376
+ `wrangler.jsonc \`vars.${name}\` is not set. ${channel} is a no-op; ` +
377
+ `data captured in this channel never lands in ClickHouse. ` +
378
+ `See docs/observability.md for the canonical endpoints.`,
379
+ fix: "npx -p @decocms/start deco-cf-observability --write",
380
+ });
381
+ }
382
+ };
383
+ checkVar(
384
+ "otel_metrics_endpoint_missing",
385
+ "DECO_OTEL_METRICS_ENDPOINT",
386
+ "warn",
387
+ "OTLP metrics direct-POST",
388
+ );
389
+ checkVar(
390
+ "otel_traces_endpoint_missing",
391
+ "DECO_OTEL_TRACES_ENDPOINT",
392
+ "warn",
393
+ "OTLP traces direct-POST",
394
+ );
395
+ checkVar(
396
+ "otel_logs_endpoint_missing",
397
+ "DECO_OTEL_LOGS_ENDPOINT",
398
+ "warn",
399
+ "OTLP error-log direct-POST",
400
+ );
401
+
402
+ return findings;
403
+ }
404
+
405
+ /**
406
+ * One-stop call for the full wrangler audit — composes the
407
+ * observability-block rules with the fleet-binding rules. Both keep
408
+ * working standalone for fine-grained tests.
409
+ */
410
+ export function auditWranglerConfig(wrangler: WranglerLike): Finding[] {
411
+ return [
412
+ ...auditObservabilityBlock(wrangler.observability),
413
+ ...auditFleetBindings(wrangler),
414
+ ];
415
+ }
416
+
239
417
  function findingsToText(file: string, findings: Finding[]): string {
240
418
  if (findings.length === 0) {
241
419
  return `OK ${file} — observability config looks canonical`;
@@ -263,26 +441,49 @@ function main(): void {
263
441
  process.exit(2);
264
442
  }
265
443
 
266
- let parsed: { observability?: ObservabilityBlock };
444
+ let parsed: WranglerLike;
267
445
  try {
268
- parsed = parseJsonc(fs.readFileSync(file, "utf8"));
446
+ parsed = parseJsonc(fs.readFileSync(file, "utf8")) as WranglerLike;
269
447
  } catch (err) {
270
448
  console.error(`audit: ${file} could not be parsed: ${(err as Error).message}`);
271
449
  process.exit(2);
272
450
  }
273
451
 
274
- const findings = auditObservabilityBlock(parsed.observability);
452
+ const findings = auditWranglerConfig(parsed);
275
453
 
276
454
  if (opts.json) {
277
- process.stdout.write(JSON.stringify({ file, findings }, null, 2) + "\n");
455
+ process.stdout.write(
456
+ JSON.stringify({ file, mode: opts.mode, findings }, null, 2) + "\n",
457
+ );
278
458
  } else {
279
459
  process.stdout.write(findingsToText(file, findings) + "\n");
280
460
  }
281
461
 
282
- // Any finding (warn or error) is a non-zero exit. info-severity would not
283
- // flip the exit, but no info-severity rules are defined yet.
284
- const blocking = findings.some((f) => f.severity !== "info");
285
- process.exit(blocking ? 1 : 0);
462
+ if (opts.github) {
463
+ for (const f of findings) {
464
+ // GitHub Actions workflow command. `error` and `warning` annotate the
465
+ // diff; `notice` is informational. We never emit `error` in warn mode
466
+ // even for error-severity rules — the v1 policy is annotate-don't-fail.
467
+ const level = opts.mode === "block" && f.severity === "error"
468
+ ? "error"
469
+ : f.severity === "info" ? "notice" : "warning";
470
+ const msg = `${f.message}${f.fix ? ` (fix: ${f.fix})` : ""}`;
471
+ const escaped = msg.replace(/%/g, "%25").replace(/\r/g, "%0D").replace(
472
+ /\n/g,
473
+ "%0A",
474
+ );
475
+ process.stdout.write(`::${level} title=${f.id}::${escaped}\n`);
476
+ }
477
+ }
478
+
479
+ // Exit policy: D-16 / Phase 6 decision.
480
+ // warn — annotate only; always exit 0 (CI sees the findings but ships)
481
+ // block — exit 1 on any `error`-severity finding
482
+ // The default is `warn` because storefronts are upgraded over weeks; a
483
+ // day-one block would fail PRs that have nothing to do with observability.
484
+ const shouldFail = opts.mode === "block" &&
485
+ findings.some((f) => f.severity === "error");
486
+ process.exit(shouldFail ? 1 : 0);
286
487
  }
287
488
 
288
489
  // Only run when invoked directly, not when imported by tests.
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Phase 2 (D-11) coverage for the metric surface — canonical label set,
3
+ * cache_layer, commerce_request_duration_ms. The Phase 1 logger/trace
4
+ * tests live under `src/sdk/logger.test.ts` and `src/sdk/otel.test.ts`;
5
+ * this file focuses on the middleware-level helpers.
6
+ */
7
+ import { afterEach, beforeEach, describe, expect, it } from "vitest";
8
+ import {
9
+ configureMeter,
10
+ type MeterAdapter,
11
+ MetricNames,
12
+ recordCacheMetric,
13
+ recordCommerceMetric,
14
+ recordRequestMetric,
15
+ statusClassFor,
16
+ } from "./observability";
17
+
18
+ interface Counter {
19
+ name: string;
20
+ value: number;
21
+ labels?: Record<string, unknown>;
22
+ }
23
+ interface Histogram {
24
+ name: string;
25
+ value: number;
26
+ labels?: Record<string, unknown>;
27
+ }
28
+
29
+ function captureMeter(): {
30
+ adapter: MeterAdapter;
31
+ counters: Counter[];
32
+ histograms: Histogram[];
33
+ } {
34
+ const counters: Counter[] = [];
35
+ const histograms: Histogram[] = [];
36
+ const adapter: MeterAdapter = {
37
+ counterInc(name, value, labels) {
38
+ counters.push({ name, value: value ?? 1, labels });
39
+ },
40
+ histogramRecord(name, value, labels) {
41
+ histograms.push({ name, value, labels });
42
+ },
43
+ };
44
+ return { adapter, counters, histograms };
45
+ }
46
+
47
+ describe("statusClassFor", () => {
48
+ it("maps 2xx / 3xx / 4xx / 5xx to canonical class labels", () => {
49
+ expect(statusClassFor(200)).toBe("2xx");
50
+ expect(statusClassFor(204)).toBe("2xx");
51
+ expect(statusClassFor(301)).toBe("3xx");
52
+ expect(statusClassFor(404)).toBe("4xx");
53
+ expect(statusClassFor(500)).toBe("5xx");
54
+ expect(statusClassFor(503)).toBe("5xx");
55
+ });
56
+
57
+ it("returns 'unknown' for out-of-range / NaN / non-numeric inputs", () => {
58
+ expect(statusClassFor(-1)).toBe("unknown");
59
+ expect(statusClassFor(99)).toBe("unknown");
60
+ expect(statusClassFor(600)).toBe("unknown");
61
+ expect(statusClassFor(Number.NaN)).toBe("unknown");
62
+ expect(statusClassFor(Infinity)).toBe("unknown");
63
+ });
64
+ });
65
+
66
+ describe("recordRequestMetric — canonical labels (D-11)", () => {
67
+ afterEach(() => {
68
+ // Reset meter so other tests start clean.
69
+ configureMeter({ counterInc: () => {} });
70
+ });
71
+
72
+ it("stamps method + route_pattern + status + status_class by default", () => {
73
+ const { adapter, counters, histograms } = captureMeter();
74
+ configureMeter(adapter);
75
+
76
+ recordRequestMetric("GET", "/products/abc123/p", 200, 42);
77
+
78
+ expect(counters).toHaveLength(1);
79
+ expect(counters[0]?.name).toBe(MetricNames.HTTP_REQUESTS_TOTAL);
80
+ expect(counters[0]?.labels).toMatchObject({
81
+ method: "GET",
82
+ // Default normalization: dynamic segments collapsed.
83
+ route_pattern: "/products/:slug/p",
84
+ status: 200,
85
+ status_class: "2xx",
86
+ });
87
+ expect(histograms).toHaveLength(1);
88
+ expect(histograms[0]?.name).toBe(MetricNames.HTTP_REQUEST_DURATION_MS);
89
+ expect(histograms[0]?.value).toBe(42);
90
+ });
91
+
92
+ it("prefers caller-supplied route_pattern over normalized path", () => {
93
+ const { adapter, counters } = captureMeter();
94
+ configureMeter(adapter);
95
+
96
+ recordRequestMetric("GET", "/anything/random/123", 200, 5, {
97
+ route_pattern: "/_products/$slug/p",
98
+ });
99
+
100
+ expect(counters[0]?.labels?.route_pattern).toBe("/_products/$slug/p");
101
+ });
102
+
103
+ it("emits http_request_errors_total on 5xx", () => {
104
+ const { adapter, counters } = captureMeter();
105
+ configureMeter(adapter);
106
+
107
+ recordRequestMetric("POST", "/checkout", 503, 120);
108
+
109
+ const errCounter = counters.find((c) => c.name === MetricNames.HTTP_REQUEST_ERRORS);
110
+ expect(errCounter).toBeDefined();
111
+ expect(errCounter?.labels?.status_class).toBe("5xx");
112
+ });
113
+
114
+ it("propagates optional labels (outcome, cache_decision, cache_layer, region, extra)", () => {
115
+ const { adapter, counters } = captureMeter();
116
+ configureMeter(adapter);
117
+
118
+ recordRequestMetric("GET", "/", 200, 10, {
119
+ outcome: "ok",
120
+ cache_decision: "STALE-HIT",
121
+ cache_layer: "edge",
122
+ region: "GRU",
123
+ extra: { ab_variant: "B" },
124
+ });
125
+
126
+ expect(counters[0]?.labels).toMatchObject({
127
+ outcome: "ok",
128
+ cache_decision: "STALE-HIT",
129
+ cache_layer: "edge",
130
+ region: "GRU",
131
+ ab_variant: "B",
132
+ });
133
+ });
134
+
135
+ it("is a no-op when no meter is configured", () => {
136
+ // We can't easily prove a no-op other than verifying no throw —
137
+ // safer than calling configureMeter(null), which would mask real
138
+ // bugs. The previous test's `afterEach` reset already gives us a
139
+ // bare meter; this test confirms the call is benign.
140
+ expect(() => recordRequestMetric("GET", "/", 200, 1)).not.toThrow();
141
+ });
142
+ });
143
+
144
+ describe("recordCacheMetric — cache_layer label", () => {
145
+ beforeEach(() => {
146
+ configureMeter({ counterInc: () => {} });
147
+ });
148
+
149
+ it("stamps profile + decision + layer when all are provided", () => {
150
+ const { adapter, counters } = captureMeter();
151
+ configureMeter(adapter);
152
+
153
+ recordCacheMetric(true, "product", "HIT", "edge");
154
+
155
+ expect(counters).toHaveLength(1);
156
+ expect(counters[0]?.name).toBe(MetricNames.CACHE_HIT);
157
+ expect(counters[0]?.labels).toMatchObject({
158
+ profile: "product",
159
+ decision: "HIT",
160
+ layer: "edge",
161
+ });
162
+ });
163
+
164
+ it("emits cache_miss_total when hit=false", () => {
165
+ const { adapter, counters } = captureMeter();
166
+ configureMeter(adapter);
167
+
168
+ recordCacheMetric(false, "search", "MISS", "edge");
169
+
170
+ expect(counters[0]?.name).toBe(MetricNames.CACHE_MISS);
171
+ });
172
+
173
+ it("supports the legacy 3-arg signature for backward compat", () => {
174
+ const { adapter, counters } = captureMeter();
175
+ configureMeter(adapter);
176
+
177
+ recordCacheMetric(true, "static");
178
+
179
+ expect(counters[0]?.labels).toEqual({ profile: "static" });
180
+ });
181
+
182
+ it("distinguishes cachedLoader vs edge vs vtex-swr layers", () => {
183
+ const { adapter, counters } = captureMeter();
184
+ configureMeter(adapter);
185
+
186
+ recordCacheMetric(true, "loader-x", "HIT", "cachedLoader");
187
+ recordCacheMetric(true, "vtex-product", "HIT", "vtex-swr");
188
+
189
+ expect(counters[0]?.labels?.layer).toBe("cachedLoader");
190
+ expect(counters[1]?.labels?.layer).toBe("vtex-swr");
191
+ });
192
+ });
193
+
194
+ describe("recordCommerceMetric (D-11)", () => {
195
+ beforeEach(() => {
196
+ configureMeter({ counterInc: () => {} });
197
+ });
198
+
199
+ it("emits commerce_request_duration_ms with provider + operation labels", () => {
200
+ const { adapter, histograms } = captureMeter();
201
+ configureMeter(adapter);
202
+
203
+ recordCommerceMetric(123, {
204
+ provider: "vtex",
205
+ operation: "intelligent-search.product_search",
206
+ status_class: "2xx",
207
+ });
208
+
209
+ expect(histograms).toHaveLength(1);
210
+ expect(histograms[0]?.name).toBe(MetricNames.COMMERCE_REQUEST_DURATION_MS);
211
+ expect(histograms[0]?.value).toBe(123);
212
+ expect(histograms[0]?.labels).toMatchObject({
213
+ provider: "vtex",
214
+ operation: "intelligent-search.product_search",
215
+ status_class: "2xx",
216
+ });
217
+ });
218
+
219
+ it("includes the cached boolean when provided", () => {
220
+ const { adapter, histograms } = captureMeter();
221
+ configureMeter(adapter);
222
+
223
+ recordCommerceMetric(5, {
224
+ provider: "shopify",
225
+ operation: "graphql.cart_query",
226
+ cached: true,
227
+ });
228
+
229
+ expect(histograms[0]?.labels?.cached).toBe(true);
230
+ });
231
+
232
+ it("is a no-op when no meter is configured", () => {
233
+ expect(() =>
234
+ recordCommerceMetric(1, { provider: "vtex", operation: "test" }),
235
+ ).not.toThrow();
236
+ });
237
+ });