@decocms/blocks 7.37.4 → 7.37.5

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.37.4",
3
+ "version": "7.37.5",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -7,9 +7,11 @@
7
7
  import { afterEach, beforeEach, describe, expect, it } from "vitest";
8
8
  import {
9
9
  configureMeter,
10
- normalizePath,
10
+ DURATION_BUCKET_BOUNDARIES_SECONDS,
11
+ METRIC_METADATA,
11
12
  type MeterAdapter,
12
13
  MetricNames,
14
+ normalizePath,
13
15
  recordCacheMetric,
14
16
  recordCommerceMetric,
15
17
  recordRequestMetric,
@@ -258,6 +260,46 @@ describe("recordCommerceMetric (D-11)", () => {
258
260
  });
259
261
  });
260
262
 
263
+ /**
264
+ * Guards the unit/bucket agreement. Durations are recorded in seconds, but the
265
+ * OTel SDK's default explicit buckets are milliseconds
266
+ * (`[0, 5, 10, 25, 50, 75, 100, 250, 500, 1000]`). A duration metric that ships
267
+ * without `boundaries` inherits those defaults and files ~100% of real traffic
268
+ * into the first bucket, which silently destroys every quantile computed from
269
+ * it. These assertions exist so that regression fails here instead of in a
270
+ * dashboard six months later.
271
+ */
272
+ describe("METRIC_METADATA duration buckets", () => {
273
+ it("declares second-scale boundaries for every metric measured in seconds", () => {
274
+ const secondValued = Object.entries(METRIC_METADATA).filter(
275
+ ([, meta]) => meta.unit === "s",
276
+ );
277
+
278
+ expect(secondValued.length).toBeGreaterThan(0);
279
+
280
+ for (const [name, meta] of secondValued) {
281
+ expect(meta.boundaries, `${name} must declare boundaries`).toBeDefined();
282
+ // A millisecond-scaled bucket set starts at 5; a second-scaled one starts
283
+ // well below 1. This is the exact mistake being guarded against.
284
+ expect(meta.boundaries![0], `${name} boundaries look like milliseconds`)
285
+ .toBeLessThan(1);
286
+ }
287
+ });
288
+
289
+ it("keeps boundaries sorted ascending and free of duplicates", () => {
290
+ const b = DURATION_BUCKET_BOUNDARIES_SECONDS;
291
+ expect([...b]).toEqual([...new Set(b)]);
292
+ expect([...b]).toEqual([...b].sort((x, y) => x - y));
293
+ });
294
+
295
+ it("covers the latency range real traffic falls in", () => {
296
+ // Observed production averages sat between 0.14s and 0.49s, with a tail
297
+ // past 10s on uncached loader paths. Buckets must resolve both ends.
298
+ expect(DURATION_BUCKET_BOUNDARIES_SECONDS.some((x) => x <= 0.05)).toBe(true);
299
+ expect(DURATION_BUCKET_BOUNDARIES_SECONDS.some((x) => x >= 10)).toBe(true);
300
+ });
301
+ });
302
+
261
303
  /**
262
304
  * `http.route` is the highest-cardinality risk on the highest-volume metric.
263
305
  * These cases are the real paths measured on production when the label reached
@@ -27,7 +27,23 @@
27
27
  *
28
28
  * configureMeter({
29
29
  * counterInc: (name, value, labels) => metrics.getMeter("deco").createCounter(name).add(value, labels),
30
- * histogramRecord: (name, value, labels) => metrics.getMeter("deco").createHistogram(name).record(value, labels),
30
+ * histogramRecord: (name, value, labels) => {
31
+ * // METRIC_METADATA carries the unit AND the bucket boundaries. Forwarding
32
+ * // `boundaries` to `advice` is required, not optional: durations are
33
+ * // recorded in seconds and the SDK's default buckets are milliseconds, so
34
+ * // an adapter that omits it files everything into the first bucket and
35
+ * // every quantile reads back as ~0.
36
+ * const meta = METRIC_METADATA[name];
37
+ * metrics.getMeter("deco")
38
+ * .createHistogram(name, {
39
+ * unit: meta?.unit,
40
+ * description: meta?.description,
41
+ * advice: meta?.boundaries
42
+ * ? { explicitBucketBoundaries: [...meta.boundaries] }
43
+ * : undefined,
44
+ * })
45
+ * .record(value, labels);
46
+ * },
31
47
  * });
32
48
  * ```
33
49
  */
@@ -271,23 +287,69 @@ export const MetricNames = {
271
287
  LOADER_ERRORS: "deco.loader.errors",
272
288
  } as const;
273
289
 
290
+ /**
291
+ * Explicit bucket boundaries, in SECONDS, for every duration histogram declared
292
+ * in {@link METRIC_METADATA}.
293
+ *
294
+ * These exist because the unit and the buckets are configured in two different
295
+ * places and had drifted apart. The record helpers divide by 1000, so values
296
+ * arrive in seconds — correct per SemConv. But the OTel SDK's *default*
297
+ * boundaries are `[0, 5, 10, 25, 50, 75, 100, 250, 500, 1000]`, which are
298
+ * milliseconds. An adapter that calls `createHistogram(name)` without an
299
+ * `advice` therefore files every sub-5-second observation into the first bucket.
300
+ *
301
+ * Measured on the production ClickHouse before this change: 99.8%–99.95% of all
302
+ * observations on `http.server.request.duration` (78.7M/hour),
303
+ * `http.client.request.duration`, `deco.loader.duration` and
304
+ * `deco.cms.resolve.duration` sat in bucket 1, so every quantile read back as
305
+ * ~0 and the latency dashboards had to fall back to tail-sampled traces.
306
+ *
307
+ * Values are the SemConv-recommended defaults for HTTP duration histograms.
308
+ */
309
+ export const DURATION_BUCKET_BOUNDARIES_SECONDS: readonly number[] = [
310
+ 0.005,
311
+ 0.01,
312
+ 0.025,
313
+ 0.05,
314
+ 0.075,
315
+ 0.1,
316
+ 0.25,
317
+ 0.5,
318
+ 0.75,
319
+ 1,
320
+ 2.5,
321
+ 5,
322
+ 7.5,
323
+ 10,
324
+ ];
325
+
274
326
  /**
275
327
  * Per-metric metadata emitted in the OTLP payload's `description` and
276
328
  * `unit` fields. Durations are normalized to **seconds at the source** (OTel
277
329
  * semconv), matching the deco-cx/deco framework — NOT converted downstream in
278
330
  * the collector. Callers still pass milliseconds; the record helpers divide.
279
331
  *
332
+ * `boundaries`, when present, MUST be forwarded to the histogram's `advice` at
333
+ * creation time — the unit alone does not tell the SDK how to bucket, and its
334
+ * defaults assume milliseconds. See
335
+ * {@link DURATION_BUCKET_BOUNDARIES_SECONDS}.
336
+ *
280
337
  * Keep keys aligned with `MetricNames` values so a missing entry is a
281
338
  * type error at compile time when a new metric ships without metadata.
282
339
  */
283
- export const METRIC_METADATA: Record<string, { description: string; unit: string }> = {
340
+ export const METRIC_METADATA: Record<
341
+ string,
342
+ { description: string; unit: string; boundaries?: readonly number[] }
343
+ > = {
284
344
  [MetricNames.HTTP_SERVER_REQUEST_DURATION]: {
285
345
  description: "Duration of HTTP server requests handled at the Worker entry point.",
286
346
  unit: "s",
347
+ boundaries: DURATION_BUCKET_BOUNDARIES_SECONDS,
287
348
  },
288
349
  [MetricNames.HTTP_CLIENT_REQUEST_DURATION]: {
289
350
  description: "Duration of outbound HTTP client requests (commerce, generic fetch).",
290
351
  unit: "s",
352
+ boundaries: DURATION_BUCKET_BOUNDARIES_SECONDS,
291
353
  },
292
354
  [MetricNames.CACHE_REQUESTS]: {
293
355
  description: "Cache lookups, dimensioned by status (hit/stale/miss/bypass).",
@@ -296,10 +358,12 @@ export const METRIC_METADATA: Record<string, { description: string; unit: string
296
358
  [MetricNames.RESOLVE_DURATION]: {
297
359
  description: "Duration of `deco.cms.resolvePage` — CMS route to block tree resolution.",
298
360
  unit: "s",
361
+ boundaries: DURATION_BUCKET_BOUNDARIES_SECONDS,
299
362
  },
300
363
  [MetricNames.LOADER_DURATION]: {
301
364
  description: "Per-loader execution duration, emitted by cachedLoader.",
302
365
  unit: "s",
366
+ boundaries: DURATION_BUCKET_BOUNDARIES_SECONDS,
303
367
  },
304
368
  [MetricNames.LOADER_ERRORS]: {
305
369
  description: "Per-loader error count.",