@decocms/blocks 7.62.5 → 7.63.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.62.5",
3
+ "version": "7.63.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -282,6 +282,9 @@ export const MetricNames = {
282
282
  // Labels on this counter use short keys (status/profile/layer/provider) —
283
283
  // the metric name already provides the deco.cache.* namespace.
284
284
  CACHE_REQUESTS: "deco.cache.requests",
285
+ // Size in bytes of the values moving through a cache, dimensioned by `op`.
286
+ // Bucketed in decades of bytes — see CACHE_SIZE_BUCKET_BOUNDARIES_BYTES.
287
+ CACHE_SIZE: "deco.cache.size",
285
288
  RESOLVE_DURATION: "deco.cms.resolve.duration",
286
289
  LOADER_DURATION: "deco.loader.duration",
287
290
  LOADER_ERRORS: "deco.loader.errors",
@@ -323,6 +326,29 @@ export const DURATION_BUCKET_BOUNDARIES_SECONDS: readonly number[] = [
323
326
  10,
324
327
  ];
325
328
 
329
+ /**
330
+ * Bucket boundaries, in BYTES, for {@link MetricNames.CACHE_SIZE}.
331
+ *
332
+ * Decades, matching the `http.server.response.body.size` histogram already
333
+ * flowing through the same ingestor — a size distribution is read in orders of
334
+ * magnitude, not in linear steps, and reusing the house convention means the
335
+ * two byte histograms are directly comparable.
336
+ *
337
+ * The range is bounded on both ends by the loader cache itself: entries are
338
+ * floored at `MIN_ENTRY_BYTES` (512 B) and the store is capped at 32 MB, which
339
+ * is why the last bound is the cap rather than another decade — the top bucket
340
+ * then reads as "single entry at/near the whole cache budget", which is the
341
+ * thing worth alerting on.
342
+ */
343
+ export const CACHE_SIZE_BUCKET_BOUNDARIES_BYTES: readonly number[] = [
344
+ 1_000,
345
+ 10_000,
346
+ 100_000,
347
+ 1_000_000,
348
+ 10_000_000,
349
+ 33_554_432,
350
+ ];
351
+
326
352
  /**
327
353
  * Per-metric metadata emitted in the OTLP payload's `description` and
328
354
  * `unit` fields. Durations are normalized to **seconds at the source** (OTel
@@ -339,7 +365,11 @@ export const DURATION_BUCKET_BOUNDARIES_SECONDS: readonly number[] = [
339
365
  */
340
366
  export const METRIC_METADATA: Record<
341
367
  string,
342
- { description: string; unit: string; boundaries?: readonly number[] }
368
+ {
369
+ description: string;
370
+ unit: string;
371
+ boundaries?: readonly number[];
372
+ }
343
373
  > = {
344
374
  [MetricNames.HTTP_SERVER_REQUEST_DURATION]: {
345
375
  description: "Duration of HTTP server requests handled at the Worker entry point.",
@@ -355,6 +385,11 @@ export const METRIC_METADATA: Record<
355
385
  description: "Cache lookups, dimensioned by status (hit/stale/miss/bypass).",
356
386
  unit: "{request}",
357
387
  },
388
+ [MetricNames.CACHE_SIZE]: {
389
+ description: "Size in bytes of values written to / read from a cache, dimensioned by op.",
390
+ unit: "By",
391
+ boundaries: CACHE_SIZE_BUCKET_BOUNDARIES_BYTES,
392
+ },
358
393
  [MetricNames.RESOLVE_DURATION]: {
359
394
  description: "Duration of `deco.cms.resolvePage` — CMS route to block tree resolution.",
360
395
  unit: "s",
@@ -590,6 +625,22 @@ export function recordCacheMetric(
590
625
  m.counterInc(MetricNames.CACHE_REQUESTS, 1, labels);
591
626
  }
592
627
 
628
+ /**
629
+ * Record the size of a value moving through a cache, in BYTES — not seconds,
630
+ * so unlike the duration helpers this one does not divide.
631
+ *
632
+ * `profile` deliberately reuses the label key {@link recordCacheMetric} already
633
+ * uses for the `cachedLoader` layer (there it carries the loader name), so
634
+ * `deco.cache.size` joins `deco.cache.requests{layer="cachedLoader"}` on
635
+ * `profile` with no renaming in the query.
636
+ *
637
+ * Only `op: "set"` is emitted today; the label exists so a future read-side
638
+ * measurement lands on the same series without a breaking dashboard change.
639
+ */
640
+ export function recordCacheSizeMetric(bytes: number, profile: string, op: "set" | "get" = "set") {
641
+ getState().meter?.histogramRecord?.(MetricNames.CACHE_SIZE, bytes, { op, profile });
642
+ }
643
+
593
644
  /**
594
645
  * Labels for the outbound commerce sample recorded on
595
646
  * `http.client.request.duration`. Owned by the framework so apps-start (and
@@ -13,6 +13,7 @@
13
13
 
14
14
  import {
15
15
  recordCacheMetric,
16
+ recordCacheSizeMetric,
16
17
  recordLoaderError,
17
18
  recordLoaderMetric,
18
19
  withTracing,
@@ -171,7 +172,13 @@ function estimateBytes(value: unknown): number {
171
172
  }
172
173
  }
173
174
 
174
- function setCacheEntry<T>(key: string, entry: CacheEntry<T>) {
175
+ /**
176
+ * The single write path into the cache — both the cold-miss and the SWR
177
+ * background-refresh callers route through here, which is why the size metric
178
+ * is emitted at this chokepoint rather than at each call site.
179
+ */
180
+ function setCacheEntry<T>(key: string, entry: CacheEntry<T>, name: string) {
181
+ recordCacheSizeMetric(entry.estimatedBytes, name);
175
182
  const prev = cache.get(key);
176
183
  if (prev) cacheBytes -= prev.estimatedBytes;
177
184
  cacheBytes += entry.estimatedBytes;
@@ -317,12 +324,16 @@ export function createCachedLoader<TProps, TResult>(
317
324
  // Skip the write if a purge cleared the cache mid-refresh — otherwise
318
325
  // we'd re-insert pre-purge data the purge was meant to drop.
319
326
  if (gen !== cacheGeneration) return;
320
- setCacheEntry(cacheKey, {
321
- value: result,
322
- createdAt: Date.now(),
323
- refreshing: false,
324
- estimatedBytes: estimateBytes(result),
325
- });
327
+ setCacheEntry(
328
+ cacheKey,
329
+ {
330
+ value: result,
331
+ createdAt: Date.now(),
332
+ refreshing: false,
333
+ estimatedBytes: estimateBytes(result),
334
+ },
335
+ name,
336
+ );
326
337
  evictIfNeeded();
327
338
  })
328
339
  .catch(() => {
@@ -362,12 +373,16 @@ export function createCachedLoader<TProps, TResult>(
362
373
  // Skip caching if a purge landed while this loader was in flight — still
363
374
  // return the fresh value to the caller, just don't persist a raced entry.
364
375
  if (gen === cacheGeneration) {
365
- setCacheEntry(cacheKey, {
366
- value: result,
367
- createdAt: Date.now(),
368
- refreshing: false,
369
- estimatedBytes: estimateBytes(result),
370
- });
376
+ setCacheEntry(
377
+ cacheKey,
378
+ {
379
+ value: result,
380
+ createdAt: Date.now(),
381
+ refreshing: false,
382
+ estimatedBytes: estimateBytes(result),
383
+ },
384
+ name,
385
+ );
371
386
  evictIfNeeded();
372
387
  }
373
388
  return result;
@@ -0,0 +1,93 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+ import { configureMeter, MetricNames } from "../middleware/observability";
3
+ import { clearLoaderCache, createCachedLoader } from "./cachedLoader";
4
+
5
+ type Sample = { name: string; value: number; labels?: Record<string, unknown> };
6
+
7
+ function fakeMeter(samples: Sample[]) {
8
+ return {
9
+ counterInc: () => {},
10
+ gaugeSet: () => {},
11
+ histogramRecord: (name: string, value: number, labels?: Record<string, unknown>) => {
12
+ samples.push({ name, value, labels });
13
+ },
14
+ };
15
+ }
16
+
17
+ const sizeSamples = (samples: Sample[]) => samples.filter((s) => s.name === MetricNames.CACHE_SIZE);
18
+
19
+ describe("deco.cache.size — emitted on every loader cache write", () => {
20
+ let samples: Sample[];
21
+
22
+ beforeEach(() => {
23
+ clearLoaderCache();
24
+ samples = [];
25
+ configureMeter(fakeMeter(samples));
26
+ });
27
+
28
+ afterEach(() => {
29
+ clearLoaderCache();
30
+ configureMeter({ counterInc: () => {} }); // drop the recorder
31
+ vi.useRealTimers();
32
+ vi.restoreAllMocks();
33
+ });
34
+
35
+ it("records one sample on the cold-miss write, labelled op=set + the loader name", async () => {
36
+ const cached = createCachedLoader("t/size-miss", async (p: { id: number }) => ({ v: p.id }), {
37
+ policy: "stale-while-revalidate",
38
+ maxAge: 60_000,
39
+ });
40
+
41
+ await cached({ id: 1 });
42
+
43
+ const sizes = sizeSamples(samples);
44
+ expect(sizes).toHaveLength(1);
45
+ expect(sizes[0].labels).toEqual({ op: "set", profile: "t/size-miss" });
46
+ // Bytes, not seconds — and floored at MIN_ENTRY_BYTES for a tiny payload.
47
+ expect(sizes[0].value).toBe(512);
48
+ });
49
+
50
+ it("does not re-record on a HIT — only writes are measured", async () => {
51
+ const cached = createCachedLoader("t/size-hit", async (p: { id: number }) => ({ v: p.id }), {
52
+ policy: "stale-while-revalidate",
53
+ maxAge: 60_000,
54
+ });
55
+
56
+ await cached({ id: 1 }); // MISS -> write
57
+ await cached({ id: 1 }); // HIT -> no write
58
+
59
+ expect(sizeSamples(samples)).toHaveLength(1);
60
+ });
61
+
62
+ it("records the SWR background-refresh write too, not just the cold miss", async () => {
63
+ vi.useFakeTimers();
64
+ vi.setSystemTime(new Date("2026-05-18T16:00:00.000Z"));
65
+
66
+ const cached = createCachedLoader("t/size-swr", async (p: { id: number }) => ({ v: p.id }), {
67
+ policy: "stale-while-revalidate",
68
+ maxAge: 1_000,
69
+ });
70
+
71
+ await cached({ id: 1 }); // MISS -> write #1
72
+ vi.setSystemTime(new Date("2026-05-18T16:00:05.000Z")); // now stale
73
+ await cached({ id: 1 }); // STALE-HIT -> background refresh
74
+ await vi.runAllTimersAsync(); // let the refresh settle
75
+
76
+ const sizes = sizeSamples(samples);
77
+ expect(sizes).toHaveLength(2); // write #2 came from the refresh
78
+ expect(sizes.every((s) => s.labels?.op === "set")).toBe(true);
79
+ expect(sizes.every((s) => s.labels?.profile === "t/size-swr")).toBe(true);
80
+ });
81
+
82
+ it("scales with the payload so a fat loader is distinguishable from a thin one", async () => {
83
+ const big = { blob: "x".repeat(50_000) };
84
+ const cached = createCachedLoader("t/size-big", async () => big, {
85
+ policy: "stale-while-revalidate",
86
+ maxAge: 60_000,
87
+ });
88
+
89
+ await cached({});
90
+
91
+ expect(sizeSamples(samples)[0].value).toBeGreaterThan(50_000);
92
+ });
93
+ });
@@ -64,6 +64,7 @@ export {
64
64
  type MeterAdapter,
65
65
  MetricNames,
66
66
  recordCacheMetric,
67
+ recordCacheSizeMetric,
67
68
  recordCommerceMetric,
68
69
  recordLoaderError,
69
70
  recordLoaderMetric,
@@ -63,6 +63,10 @@ function buildAdapter(
63
63
  nowMs?: () => number;
64
64
  onError?: (kind: "flush" | "overflow" | "kind-mismatch", err: unknown) => void;
65
65
  histogramBounds?: number[];
66
+ metricMetadata?: Record<
67
+ string,
68
+ { description?: string; unit?: string; boundaries?: readonly number[] }
69
+ >;
66
70
  } = {},
67
71
  ) {
68
72
  return createOtlpHttpMeterAdapter({
@@ -78,6 +82,7 @@ function buildAdapter(
78
82
  nowMs: overrides.nowMs,
79
83
  onError: overrides.onError,
80
84
  histogramBounds: overrides.histogramBounds,
85
+ metricMetadata: overrides.metricMetadata,
81
86
  });
82
87
  }
83
88
 
@@ -290,3 +295,101 @@ describe("createOtlpHttpMeterAdapter — buffer + flush", () => {
290
295
  expect(slow).toHaveBeenCalledTimes(1);
291
296
  });
292
297
  });
298
+
299
+ // ---------------------------------------------------------------------------
300
+ // Per-metric bucket boundaries
301
+ // ---------------------------------------------------------------------------
302
+
303
+ interface HistoMetric {
304
+ name: string;
305
+ unit?: string;
306
+ histogram?: {
307
+ dataPoints: Array<{
308
+ attributes: Array<{ key: string; value: Record<string, unknown> }>;
309
+ count: string;
310
+ sum: number;
311
+ bucketCounts: string[];
312
+ explicitBounds: number[];
313
+ }>;
314
+ };
315
+ }
316
+
317
+ function histoMetrics(calls: Array<{ init?: RequestInit }>): HistoMetric[] {
318
+ const payload = JSON.parse(calls[0].init!.body as string) as {
319
+ resourceMetrics: Array<{ scopeMetrics: Array<{ metrics: HistoMetric[] }> }>;
320
+ };
321
+ return payload.resourceMetrics[0].scopeMetrics[0].metrics;
322
+ }
323
+
324
+ // Decades of bytes, mirroring CACHE_SIZE_BUCKET_BOUNDARIES_BYTES.
325
+ const BYTE_BOUNDS = [1_000, 10_000, 100_000, 1_000_000, 10_000_000, 33_554_432];
326
+
327
+ describe("createOtlpHttpMeterAdapter — per-metric bucket boundaries", () => {
328
+ beforeEach(() => {
329
+ vi.useFakeTimers();
330
+ vi.setSystemTime(new Date("2026-05-18T16:00:00.000Z"));
331
+ });
332
+
333
+ afterEach(() => {
334
+ vi.useRealTimers();
335
+ vi.restoreAllMocks();
336
+ });
337
+
338
+ it("buckets a metric against its own boundaries, not the adapter default", async () => {
339
+ const { impl, calls } = captureFetch();
340
+ const meter = buildAdapter({
341
+ fetchImpl: impl,
342
+ // The ms-tuned adapter default: every byte value would saturate its
343
+ // overflow bucket, which is the regression this test pins.
344
+ histogramBounds: [5, 10, 25, 50, 75, 100, 250, 500, 1000],
345
+ metricMetadata: { "deco.cache.size": { unit: "By", boundaries: BYTE_BOUNDS } },
346
+ });
347
+
348
+ const labels = { op: "set", profile: "productList" };
349
+ meter.histogramRecord("deco.cache.size", 512, labels); // bucket 0 (<= 1k)
350
+ meter.histogramRecord("deco.cache.size", 50_000, labels); // bucket 2 (<= 100k)
351
+ meter.histogramRecord("deco.cache.size", 50_000_000, labels); // overflow (> 32M)
352
+
353
+ await meter.flush();
354
+
355
+ const dp = histoMetrics(calls)[0].histogram!.dataPoints[0];
356
+ expect(dp.explicitBounds).toEqual(BYTE_BOUNDS);
357
+ expect(dp.bucketCounts).toEqual(["1", "0", "1", "0", "0", "0", "1"]);
358
+ expect(dp.count).toBe("3");
359
+ });
360
+
361
+ it("emits the bounds the counts were filed against, per metric, in one flush", async () => {
362
+ const { impl, calls } = captureFetch();
363
+ const meter = buildAdapter({
364
+ fetchImpl: impl,
365
+ histogramBounds: [5, 10, 25],
366
+ metricMetadata: { "deco.cache.size": { unit: "By", boundaries: BYTE_BOUNDS } },
367
+ });
368
+
369
+ // Two histograms with different units in the SAME adapter — the bug was
370
+ // that both serialized with one shared array.
371
+ meter.histogramRecord("deco.cache.size", 512, { op: "set" });
372
+ meter.histogramRecord("deco.loader.duration", 7, { name: "x" });
373
+
374
+ await meter.flush();
375
+
376
+ const byName = Object.fromEntries(histoMetrics(calls).map((m) => [m.name, m]));
377
+ expect(byName["deco.cache.size"].histogram!.dataPoints[0].explicitBounds).toEqual(BYTE_BOUNDS);
378
+ expect(byName["deco.loader.duration"].histogram!.dataPoints[0].explicitBounds).toEqual([
379
+ 5, 10, 25,
380
+ ]);
381
+ });
382
+
383
+ it("falls back to the adapter default when a metric declares no boundaries", async () => {
384
+ const { impl, calls } = captureFetch();
385
+ const meter = buildAdapter({ fetchImpl: impl, histogramBounds: [10, 100] });
386
+
387
+ meter.histogramRecord("deco.loader.duration", 50, { name: "x" });
388
+
389
+ await meter.flush();
390
+
391
+ const dp = histoMetrics(calls)[0].histogram!.dataPoints[0];
392
+ expect(dp.explicitBounds).toEqual([10, 100]);
393
+ expect(dp.bucketCounts).toEqual(["0", "1", "0"]);
394
+ });
395
+ });
@@ -84,8 +84,20 @@ export interface OtlpHttpMeterOptions {
84
84
  * The framework owns the lookup; callers MUST NOT pass metadata at
85
85
  * record time. See `MetricNames` / `METRIC_METADATA` in
86
86
  * `middleware/observability.ts`.
87
+ *
88
+ * `boundaries`, when present, is ALSO used to bucket that metric, overriding
89
+ * the adapter-wide `histogramBounds`. Without this a byte-sized histogram and
90
+ * a seconds-valued one would share the single ms-tuned default and both read
91
+ * back as a single saturated bucket.
87
92
  */
88
- metricMetadata?: Record<string, { description?: string; unit?: string }>;
93
+ metricMetadata?: Record<string, MetricMetadata>;
94
+ }
95
+
96
+ export interface MetricMetadata {
97
+ description?: string;
98
+ unit?: string;
99
+ /** Explicit bucket bounds for THIS metric, in the metric's own unit. */
100
+ boundaries?: readonly number[];
89
101
  }
90
102
 
91
103
  export interface OtlpHttpMeter extends MeterAdapter {
@@ -130,6 +142,13 @@ interface MetricEntry {
130
142
  counter?: Map<string, CounterPoint>;
131
143
  gauge?: Map<string, GaugePoint>;
132
144
  histogram?: Map<string, HistogramPoint>;
145
+ /**
146
+ * Bounds this metric is bucketed with — resolved once from the metric's own
147
+ * `boundaries` metadata, falling back to the adapter-wide default. Held on
148
+ * the entry so `serializeOtlp` emits the bounds the counts were actually
149
+ * filed against; emitting a different array silently mislabels every bucket.
150
+ */
151
+ bounds?: readonly number[];
133
152
  }
134
153
 
135
154
  // ---------------------------------------------------------------------------
@@ -273,6 +292,8 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
273
292
  }
274
293
  const entry = existing ?? materializeEntry(name, "histogram");
275
294
  if (!entry.histogram) return;
295
+ const bounds = entry.bounds ?? metricMetadata[name]?.boundaries ?? histogramBounds;
296
+ entry.bounds = bounds;
276
297
  let point = entry.histogram.get(key);
277
298
  if (!point) {
278
299
  point = {
@@ -280,7 +301,7 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
280
301
  sum: 0,
281
302
  min: Number.POSITIVE_INFINITY,
282
303
  max: Number.NEGATIVE_INFINITY,
283
- bucketCounts: new Array(histogramBounds.length + 1).fill(0),
304
+ bucketCounts: new Array(bounds.length + 1).fill(0),
284
305
  attrs: labels ? { ...labels } : {},
285
306
  startTimeUnixNano: msToNs(isolateStartMs),
286
307
  };
@@ -290,10 +311,10 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
290
311
  point.sum += value;
291
312
  if (value < point.min) point.min = value;
292
313
  if (value > point.max) point.max = value;
293
- // Locate bucket. histogramBounds is small (<=20); linear scan is fine.
294
- let bucketIdx = histogramBounds.length;
295
- for (let i = 0; i < histogramBounds.length; i++) {
296
- if (value <= histogramBounds[i]) {
314
+ // Locate bucket. bounds is small (<=20); linear scan is fine.
315
+ let bucketIdx = bounds.length;
316
+ for (let i = 0; i < bounds.length; i++) {
317
+ if (value <= bounds[i]) {
297
318
  bucketIdx = i;
298
319
  break;
299
320
  }
@@ -410,7 +431,7 @@ interface SerializeOpts {
410
431
  scopeVersion: string;
411
432
  histogramBounds: number[];
412
433
  flushAtNs: string;
413
- metricMetadata: Record<string, { description?: string; unit?: string }>;
434
+ metricMetadata: Record<string, MetricMetadata>;
414
435
  }
415
436
 
416
437
  function serializeOtlp(
@@ -470,7 +491,9 @@ function serializeOtlp(
470
491
  min: point.min === Number.POSITIVE_INFINITY ? 0 : point.min,
471
492
  max: point.max === Number.NEGATIVE_INFINITY ? 0 : point.max,
472
493
  bucketCounts: point.bucketCounts.map((c) => String(c)),
473
- explicitBounds: opts.histogramBounds,
494
+ // Must be the array the counts were bucketed against, not the
495
+ // adapter-wide default — see MetricEntry.bounds.
496
+ explicitBounds: entry.bounds ?? opts.histogramBounds,
474
497
  });
475
498
  }
476
499
  otlpMetrics.push({