@decocms/blocks 7.62.4 → 7.63.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/blocks",
3
- "version": "7.62.4",
3
+ "version": "7.63.0",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -282,6 +282,10 @@ 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
+ // Exponential (base-2) aggregation — see METRIC_METADATA for the scale and
287
+ // why an explicit-bounds histogram is the wrong shape here.
288
+ CACHE_SIZE: "deco.cache.size",
285
289
  RESOLVE_DURATION: "deco.cms.resolve.duration",
286
290
  LOADER_DURATION: "deco.loader.duration",
287
291
  LOADER_ERRORS: "deco.loader.errors",
@@ -339,7 +343,21 @@ export const DURATION_BUCKET_BOUNDARIES_SECONDS: readonly number[] = [
339
343
  */
340
344
  export const METRIC_METADATA: Record<
341
345
  string,
342
- { description: string; unit: string; boundaries?: readonly number[] }
346
+ {
347
+ description: string;
348
+ unit: string;
349
+ boundaries?: readonly number[];
350
+ /**
351
+ * Histogram aggregation. Omitted means `"explicit"` — bucket into
352
+ * `boundaries`. `"exponential"` selects an OTel base-2 exponential
353
+ * histogram at {@link scale}, where bucket `i` covers
354
+ * `(2^(2^-scale))^i .. ^(i+1)]`. Adapters that don't implement it are free
355
+ * to keep treating the metric as an ordinary histogram.
356
+ */
357
+ aggregation?: "explicit" | "exponential";
358
+ /** Exponential-histogram scale. Fixed per metric; adapters never rescale. */
359
+ scale?: number;
360
+ }
343
361
  > = {
344
362
  [MetricNames.HTTP_SERVER_REQUEST_DURATION]: {
345
363
  description: "Duration of HTTP server requests handled at the Worker entry point.",
@@ -355,6 +373,19 @@ export const METRIC_METADATA: Record<
355
373
  description: "Cache lookups, dimensioned by status (hit/stale/miss/bypass).",
356
374
  unit: "{request}",
357
375
  },
376
+ [MetricNames.CACHE_SIZE]: {
377
+ description: "Size in bytes of values written to / read from a cache, dimensioned by op.",
378
+ unit: "By",
379
+ // Exponential rather than explicit bounds: entries span 512 B (the
380
+ // cachedLoader floor) to the 32 MB cap — five decades. Any fixed bound set
381
+ // wide enough for the top is useless at the bottom.
382
+ aggregation: "exponential",
383
+ // scale -1 => base 4, so buckets land on 512 B · 2 K · 8 K · 32 K · 128 K ·
384
+ // 512 K · 2 M · 8 M · 32 M: ~9 buckets over the real range, ~1.7 per
385
+ // decade. scale 0 (base 2) would double the series count without saying
386
+ // anything new about a size distribution.
387
+ scale: -1,
388
+ },
358
389
  [MetricNames.RESOLVE_DURATION]: {
359
390
  description: "Duration of `deco.cms.resolvePage` — CMS route to block tree resolution.",
360
391
  unit: "s",
@@ -590,6 +621,22 @@ export function recordCacheMetric(
590
621
  m.counterInc(MetricNames.CACHE_REQUESTS, 1, labels);
591
622
  }
592
623
 
624
+ /**
625
+ * Record the size of a value moving through a cache, in BYTES — not seconds,
626
+ * so unlike the duration helpers this one does not divide.
627
+ *
628
+ * `profile` deliberately reuses the label key {@link recordCacheMetric} already
629
+ * uses for the `cachedLoader` layer (there it carries the loader name), so
630
+ * `deco.cache.size` joins `deco.cache.requests{layer="cachedLoader"}` on
631
+ * `profile` with no renaming in the query.
632
+ *
633
+ * Only `op: "set"` is emitted today; the label exists so a future read-side
634
+ * measurement lands on the same series without a breaking dashboard change.
635
+ */
636
+ export function recordCacheSizeMetric(bytes: number, profile: string, op: "set" | "get" = "set") {
637
+ getState().meter?.histogramRecord?.(MetricNames.CACHE_SIZE, bytes, { op, profile });
638
+ }
639
+
593
640
  /**
594
641
  * Labels for the outbound commerce sample recorded on
595
642
  * `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,
@@ -78,7 +79,6 @@ export interface LoaderModule<TProps = any, TResult = any> {
78
79
  cacheKey?: (props: TProps, req?: Request) => string | null;
79
80
  }
80
81
 
81
-
82
82
  interface CacheEntry<T = unknown> {
83
83
  value: T;
84
84
  createdAt: number;
@@ -102,16 +102,41 @@ const DEFAULT_MAX_AGE = 60_000;
102
102
  const DEFAULT_MAX_CACHE_BYTES = 32 * 1024 * 1024;
103
103
 
104
104
  function resolveMaxBytes(): number {
105
- const env = typeof globalThis.process !== "undefined"
106
- ? globalThis.process.env
107
- : undefined;
105
+ const env = typeof globalThis.process !== "undefined" ? globalThis.process.env : undefined;
108
106
  const raw = env?.DECO_LOADER_CACHE_MAX_BYTES;
109
107
  if (!raw) return DEFAULT_MAX_CACHE_BYTES;
110
108
  const parsed = Number.parseInt(raw, 10);
111
109
  return Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_MAX_CACHE_BYTES;
112
110
  }
113
111
 
114
- const MAX_CACHE_BYTES = resolveMaxBytes();
112
+ let maxCacheBytes = resolveMaxBytes();
113
+
114
+ /**
115
+ * Override the loader cache byte cap at runtime.
116
+ *
117
+ * The `DECO_LOADER_CACHE_MAX_BYTES` env override above only lands when the
118
+ * Worker's `compatibility_date` is new enough for `nodejs_compat` to populate
119
+ * `process.env`. Verified in workerd: with `2024-09-23` it is empty both at
120
+ * module scope and inside the handler; with `2025-06-01` it is populated. Sites
121
+ * pinned to an older date — a common thing to do, since the date also gates
122
+ * behaviour flags they may have tuned deliberately — had a documented knob that
123
+ * silently did nothing.
124
+ *
125
+ * Call this from the site's `setup.ts`, before any loader is wrapped.
126
+ *
127
+ * Sizing note: the cap is per ISOLATE, not per site, and a Worker isolate has
128
+ * 128 MB total, shared with the bundle, the V8 heap and the render working set.
129
+ * Setting it at or near 128 MB does not give headroom — it removes the ceiling.
130
+ */
131
+ export function setLoaderCacheMaxBytes(bytes: number): void {
132
+ if (!Number.isFinite(bytes) || bytes <= 0) return;
133
+ maxCacheBytes = bytes;
134
+ }
135
+
136
+ /** Current byte cap — exported for diagnostics and tests. */
137
+ export function getLoaderCacheMaxBytes(): number {
138
+ return maxCacheBytes;
139
+ }
115
140
 
116
141
  const cache = new Map<string, CacheEntry>();
117
142
  let cacheBytes = 0;
@@ -147,7 +172,13 @@ function estimateBytes(value: unknown): number {
147
172
  }
148
173
  }
149
174
 
150
- 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);
151
182
  const prev = cache.get(key);
152
183
  if (prev) cacheBytes -= prev.estimatedBytes;
153
184
  cacheBytes += entry.estimatedBytes;
@@ -162,13 +193,11 @@ function deleteCacheEntry(key: string) {
162
193
  }
163
194
 
164
195
  function evictIfNeeded() {
165
- if (cacheBytes <= MAX_CACHE_BYTES) return;
166
- const oldest = [...cache.entries()].sort(
167
- (a, b) => a[1].createdAt - b[1].createdAt,
168
- );
196
+ if (cacheBytes <= maxCacheBytes) return;
197
+ const oldest = [...cache.entries()].sort((a, b) => a[1].createdAt - b[1].createdAt);
169
198
  for (const [key] of oldest) {
170
199
  deleteCacheEntry(key);
171
- if (cacheBytes <= MAX_CACHE_BYTES) break;
200
+ if (cacheBytes <= maxCacheBytes) break;
172
201
  }
173
202
  }
174
203
 
@@ -246,11 +275,10 @@ export function createCachedLoader<TProps, TResult>(
246
275
  recordCacheMetric(false, name, undefined, "cachedLoader");
247
276
  const devStart = performance.now();
248
277
  const promise = withInflightTimeout(
249
- withTracing(
250
- "deco.cachedLoader",
251
- () => loaderFn(props),
252
- { "deco.loader": name, "deco.cache.policy": "no-cache-dev" },
253
- ),
278
+ withTracing("deco.cachedLoader", () => loaderFn(props), {
279
+ "deco.loader": name,
280
+ "deco.cache.policy": "no-cache-dev",
281
+ }),
254
282
  `cachedLoader:dev ${cacheKey}`,
255
283
  )
256
284
  .then((r) => {
@@ -296,12 +324,16 @@ export function createCachedLoader<TProps, TResult>(
296
324
  // Skip the write if a purge cleared the cache mid-refresh — otherwise
297
325
  // we'd re-insert pre-purge data the purge was meant to drop.
298
326
  if (gen !== cacheGeneration) return;
299
- setCacheEntry(cacheKey, {
300
- value: result,
301
- createdAt: Date.now(),
302
- refreshing: false,
303
- estimatedBytes: estimateBytes(result),
304
- });
327
+ setCacheEntry(
328
+ cacheKey,
329
+ {
330
+ value: result,
331
+ createdAt: Date.now(),
332
+ refreshing: false,
333
+ estimatedBytes: estimateBytes(result),
334
+ },
335
+ name,
336
+ );
305
337
  evictIfNeeded();
306
338
  })
307
339
  .catch(() => {
@@ -341,12 +373,16 @@ export function createCachedLoader<TProps, TResult>(
341
373
  // Skip caching if a purge landed while this loader was in flight — still
342
374
  // return the fresh value to the caller, just don't persist a raced entry.
343
375
  if (gen === cacheGeneration) {
344
- setCacheEntry(cacheKey, {
345
- value: result,
346
- createdAt: Date.now(),
347
- refreshing: false,
348
- estimatedBytes: estimateBytes(result),
349
- });
376
+ setCacheEntry(
377
+ cacheKey,
378
+ {
379
+ value: result,
380
+ createdAt: Date.now(),
381
+ refreshing: false,
382
+ estimatedBytes: estimateBytes(result),
383
+ },
384
+ name,
385
+ );
350
386
  evictIfNeeded();
351
387
  }
352
388
  return result;
@@ -374,7 +410,6 @@ export function createCachedLoader<TProps, TResult>(
374
410
  };
375
411
  }
376
412
 
377
-
378
413
  // ---------------------------------------------------------------------------
379
414
  // Module loader dedup (the `@decocms/apps` cache/cacheKey convention)
380
415
  //
@@ -404,11 +439,12 @@ export function createCachedLoaderFromModule<TProps, TResult>(
404
439
  name: string,
405
440
  mod: LoaderModule<TProps, TResult>,
406
441
  ): (props: TProps, req?: Request) => Promise<TResult> {
407
- const policy: CachePolicy = typeof mod.cache === "string"
408
- ? mod.cache
409
- : mod.cache && typeof mod.cache === "object"
410
- ? "stale-while-revalidate"
411
- : "no-store";
442
+ const policy: CachePolicy =
443
+ typeof mod.cache === "string"
444
+ ? mod.cache
445
+ : mod.cache && typeof mod.cache === "object"
446
+ ? "stale-while-revalidate"
447
+ : "no-store";
412
448
 
413
449
  // Only `stale-while-revalidate` (or `{ maxAge }`) opts into dedup — matching
414
450
  // `@decocms/apps`/deco, where `no-store` (the default) and `no-cache` both
@@ -459,9 +495,7 @@ export function createLoaderEntry<TProps = any, TResult = any>(
459
495
  // first-calls — exactly the N-sections-per-render case — share one import
460
496
  // instead of racing into N. Reset on failure so a transient import error can
461
497
  // retry rather than poisoning the entry forever.
462
- let wrappedPromise:
463
- | Promise<(props: TProps, req?: Request) => Promise<TResult>>
464
- | undefined;
498
+ let wrappedPromise: Promise<(props: TProps, req?: Request) => Promise<TResult>> | undefined;
465
499
  return (props: TProps, req?: Request): Promise<TResult> => {
466
500
  if (!wrappedPromise) {
467
501
  wrappedPromise = importFn()
@@ -498,6 +532,6 @@ export function getLoaderCacheStats() {
498
532
  entries: cache.size,
499
533
  inflight: inflightRequests.size,
500
534
  estimatedBytes: cacheBytes,
501
- maxBytes: MAX_CACHE_BYTES,
535
+ maxBytes: maxCacheBytes,
502
536
  };
503
537
  }
@@ -0,0 +1,23 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { getLoaderCacheMaxBytes, setLoaderCacheMaxBytes } from "./cachedLoader";
3
+
4
+ describe("loader cache byte cap", () => {
5
+ it("defaults to 32 MB", () => {
6
+ // process.env is empty in workerd unless compatibility_date is new enough,
7
+ // which is exactly why the setter below exists.
8
+ expect(getLoaderCacheMaxBytes()).toBe(32 * 1024 * 1024);
9
+ });
10
+
11
+ it("can be raised programmatically", () => {
12
+ setLoaderCacheMaxBytes(64 * 1024 * 1024);
13
+ expect(getLoaderCacheMaxBytes()).toBe(64 * 1024 * 1024);
14
+ });
15
+
16
+ it("ignores nonsense instead of disabling eviction", () => {
17
+ setLoaderCacheMaxBytes(64 * 1024 * 1024);
18
+ for (const bad of [0, -1, Number.NaN, Number.POSITIVE_INFINITY]) {
19
+ setLoaderCacheMaxBytes(bad);
20
+ expect(getLoaderCacheMaxBytes()).toBe(64 * 1024 * 1024);
21
+ }
22
+ });
23
+ });
@@ -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,15 @@ 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
+ {
69
+ description?: string;
70
+ unit?: string;
71
+ aggregation?: "explicit" | "exponential";
72
+ scale?: number;
73
+ }
74
+ >;
66
75
  } = {},
67
76
  ) {
68
77
  return createOtlpHttpMeterAdapter({
@@ -78,6 +87,7 @@ function buildAdapter(
78
87
  nowMs: overrides.nowMs,
79
88
  onError: overrides.onError,
80
89
  histogramBounds: overrides.histogramBounds,
90
+ metricMetadata: overrides.metricMetadata,
81
91
  });
82
92
  }
83
93
 
@@ -290,3 +300,166 @@ describe("createOtlpHttpMeterAdapter — buffer + flush", () => {
290
300
  expect(slow).toHaveBeenCalledTimes(1);
291
301
  });
292
302
  });
303
+
304
+ // ---------------------------------------------------------------------------
305
+ // Exponential histograms (deco.cache.size)
306
+ // ---------------------------------------------------------------------------
307
+
308
+ interface ExpHistogramMetric {
309
+ name: string;
310
+ unit?: string;
311
+ histogram?: unknown;
312
+ exponentialHistogram?: {
313
+ aggregationTemporality: number;
314
+ dataPoints: Array<{
315
+ attributes: Array<{ key: string; value: Record<string, unknown> }>;
316
+ count: string;
317
+ sum: number;
318
+ min: number;
319
+ max: number;
320
+ scale: number;
321
+ zeroCount: string;
322
+ positive: { offset: number; bucketCounts: string[] };
323
+ }>;
324
+ };
325
+ }
326
+
327
+ function metricsFrom(calls: Array<{ init?: RequestInit }>): ExpHistogramMetric[] {
328
+ const payload = JSON.parse(calls[0].init!.body as string) as {
329
+ resourceMetrics: Array<{ scopeMetrics: Array<{ metrics: ExpHistogramMetric[] }> }>;
330
+ };
331
+ return payload.resourceMetrics[0].scopeMetrics[0].metrics;
332
+ }
333
+
334
+ const CACHE_SIZE_META = {
335
+ "deco.cache.size": {
336
+ description: "Size in bytes of values written to / read from a cache, dimensioned by op.",
337
+ unit: "By",
338
+ aggregation: "exponential" as const,
339
+ // base 4 — the scale the framework declares for byte sizes.
340
+ scale: -1,
341
+ },
342
+ };
343
+
344
+ describe("createOtlpHttpMeterAdapter — exponential histogram", () => {
345
+ beforeEach(() => {
346
+ vi.useFakeTimers();
347
+ vi.setSystemTime(new Date("2026-05-18T16:00:00.000Z"));
348
+ });
349
+
350
+ afterEach(() => {
351
+ vi.useRealTimers();
352
+ vi.restoreAllMocks();
353
+ });
354
+
355
+ it("buckets byte sizes at scale -1 and exports OTLP exponentialHistogram", async () => {
356
+ const { impl, calls } = captureFetch();
357
+ const meter = buildAdapter({ fetchImpl: impl, metricMetadata: CACHE_SIZE_META });
358
+
359
+ // base 4. Bucket i covers (4^i, 4^(i+1)], upper-inclusive:
360
+ // 512 -> index 4 (256, 1024]
361
+ // 1024 -> index 4 (256, 1024] — exact power of the base stays LOW
362
+ // 4096 -> index 5 (1024, 4096]
363
+ const labels = { op: "set", profile: "productList" };
364
+ meter.histogramRecord("deco.cache.size", 512, labels);
365
+ meter.histogramRecord("deco.cache.size", 1024, labels);
366
+ meter.histogramRecord("deco.cache.size", 4096, labels);
367
+
368
+ await meter.flush();
369
+
370
+ const m = metricsFrom(calls)[0];
371
+ expect(m.name).toBe("deco.cache.size");
372
+ expect(m.unit).toBe("By");
373
+ // Must NOT be serialized as an explicit-bounds histogram.
374
+ expect(m.histogram).toBeUndefined();
375
+
376
+ const dp = m.exponentialHistogram!.dataPoints[0];
377
+ expect(m.exponentialHistogram!.aggregationTemporality).toBe(2); // CUMULATIVE
378
+ expect(dp.scale).toBe(-1);
379
+ expect(dp.count).toBe("3");
380
+ expect(dp.sum).toBe(512 + 1024 + 4096);
381
+ expect(dp.min).toBe(512);
382
+ expect(dp.max).toBe(4096);
383
+ expect(dp.zeroCount).toBe("0");
384
+ // offset is the index of bucketCounts[0]; two counts in bucket 4, one in 5.
385
+ expect(dp.positive.offset).toBe(4);
386
+ expect(dp.positive.bucketCounts).toEqual(["2", "1"]);
387
+ expect(dp.attributes.map((a) => a.key)).toEqual(["op", "profile"]);
388
+ });
389
+
390
+ it("fills gaps between populated buckets so offset+i stays aligned", async () => {
391
+ const { impl, calls } = captureFetch();
392
+ const meter = buildAdapter({ fetchImpl: impl, metricMetadata: CACHE_SIZE_META });
393
+
394
+ // 512 -> index 4, 32 MB -> index 12. Everything between must be zero-filled,
395
+ // otherwise the consumer reads the top bucket at the wrong boundary.
396
+ meter.histogramRecord("deco.cache.size", 512, { op: "set" });
397
+ meter.histogramRecord("deco.cache.size", 32 * 1024 * 1024, { op: "set" });
398
+
399
+ await meter.flush();
400
+
401
+ const dp = metricsFrom(calls)[0].exponentialHistogram!.dataPoints[0];
402
+ expect(dp.positive.offset).toBe(4);
403
+ expect(dp.positive.bucketCounts).toEqual(["1", "0", "0", "0", "0", "0", "0", "0", "1"]);
404
+ });
405
+
406
+ it("splits series per attr-key, like the explicit histogram does", async () => {
407
+ const { impl, calls } = captureFetch();
408
+ const meter = buildAdapter({ fetchImpl: impl, metricMetadata: CACHE_SIZE_META });
409
+
410
+ meter.histogramRecord("deco.cache.size", 512, { op: "set", profile: "a" });
411
+ meter.histogramRecord("deco.cache.size", 512, { op: "set", profile: "b" });
412
+
413
+ await meter.flush();
414
+
415
+ expect(metricsFrom(calls)[0].exponentialHistogram!.dataPoints).toHaveLength(2);
416
+ expect(meter.pendingDatapointCount()).toBe(2);
417
+ });
418
+
419
+ it("folds non-positive observations into zeroCount instead of a bucket", async () => {
420
+ const { impl, calls } = captureFetch();
421
+ const meter = buildAdapter({ fetchImpl: impl, metricMetadata: CACHE_SIZE_META });
422
+
423
+ meter.histogramRecord("deco.cache.size", 0, { op: "set" });
424
+ meter.histogramRecord("deco.cache.size", 512, { op: "set" });
425
+
426
+ await meter.flush();
427
+
428
+ const dp = metricsFrom(calls)[0].exponentialHistogram!.dataPoints[0];
429
+ expect(dp.zeroCount).toBe("1");
430
+ expect(dp.count).toBe("2");
431
+ expect(dp.positive.bucketCounts).toEqual(["1"]);
432
+ });
433
+
434
+ it("leaves histograms without exponential metadata on the explicit-bounds path", async () => {
435
+ const { impl, calls } = captureFetch();
436
+ const meter = buildAdapter({
437
+ fetchImpl: impl,
438
+ histogramBounds: [10, 100],
439
+ metricMetadata: CACHE_SIZE_META,
440
+ });
441
+
442
+ meter.histogramRecord("deco.loader.duration", 50, { name: "x" });
443
+
444
+ await meter.flush();
445
+
446
+ const m = metricsFrom(calls)[0] as ExpHistogramMetric & {
447
+ histogram?: { dataPoints: Array<{ bucketCounts: string[]; explicitBounds: number[] }> };
448
+ };
449
+ expect(m.exponentialHistogram).toBeUndefined();
450
+ expect(m.histogram!.dataPoints[0].explicitBounds).toEqual([10, 100]);
451
+ expect(m.histogram!.dataPoints[0].bucketCounts).toEqual(["0", "1", "0"]);
452
+ });
453
+
454
+ it("rejects reusing an exponential-histogram name as a counter", async () => {
455
+ const onError = vi.fn();
456
+ const { impl } = captureFetch();
457
+ const meter = buildAdapter({ fetchImpl: impl, metricMetadata: CACHE_SIZE_META, onError });
458
+
459
+ meter.histogramRecord("deco.cache.size", 512, { op: "set" });
460
+ meter.counterInc("deco.cache.size", 1, { op: "set" });
461
+
462
+ expect(onError).toHaveBeenCalledWith("kind-mismatch", expect.any(Error));
463
+ expect(meter.pendingDatapointCount()).toBe(1);
464
+ });
465
+ });
@@ -84,8 +84,19 @@ 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
+ * `aggregation: "exponential"` also routes `histogramRecord` for that name
89
+ * to a base-2 exponential histogram at `scale` — no separate adapter method,
90
+ * so call sites are identical for both aggregations.
87
91
  */
88
- metricMetadata?: Record<string, { description?: string; unit?: string }>;
92
+ metricMetadata?: Record<string, MetricMetadata>;
93
+ }
94
+
95
+ export interface MetricMetadata {
96
+ description?: string;
97
+ unit?: string;
98
+ aggregation?: "explicit" | "exponential";
99
+ scale?: number;
89
100
  }
90
101
 
91
102
  export interface OtlpHttpMeter extends MeterAdapter {
@@ -103,7 +114,7 @@ export interface OtlpHttpMeter extends MeterAdapter {
103
114
  // Internal buffer shapes
104
115
  // ---------------------------------------------------------------------------
105
116
 
106
- type MetricKind = "counter" | "gauge" | "histogram";
117
+ type MetricKind = "counter" | "gauge" | "histogram" | "expHistogram";
107
118
 
108
119
  interface CounterPoint {
109
120
  value: number;
@@ -125,11 +136,26 @@ interface HistogramPoint {
125
136
  startTimeUnixNano: string;
126
137
  }
127
138
 
139
+ interface ExpHistogramPoint {
140
+ count: number;
141
+ sum: number;
142
+ min: number;
143
+ max: number;
144
+ zeroCount: number;
145
+ /** Sparse index -> count. Densified to `positive.bucketCounts` at flush. */
146
+ buckets: Map<number, number>;
147
+ attrs: Labels;
148
+ startTimeUnixNano: string;
149
+ }
150
+
128
151
  interface MetricEntry {
129
152
  kind: MetricKind;
130
153
  counter?: Map<string, CounterPoint>;
131
154
  gauge?: Map<string, GaugePoint>;
132
155
  histogram?: Map<string, HistogramPoint>;
156
+ expHistogram?: Map<string, ExpHistogramPoint>;
157
+ /** Only set for `expHistogram`, captured from metadata at first record. */
158
+ scale?: number;
133
159
  }
134
160
 
135
161
  // ---------------------------------------------------------------------------
@@ -143,6 +169,18 @@ const DEFAULT_HISTOGRAM_BOUNDS = [
143
169
  5, 10, 25, 50, 75, 100, 250, 500, 1000,
144
170
  ];
145
171
 
172
+ /** Fallback scale when a metric declares `exponential` without one: base 2. */
173
+ const DEFAULT_EXPONENTIAL_SCALE = 0;
174
+
175
+ /**
176
+ * Defensive cap on distinct buckets per exponential series. At the scales we
177
+ * use (base 4 for byte sizes) the real range needs ~9, so hitting this means
178
+ * the metric is being fed values it wasn't sized for. Rather than let the
179
+ * sparse Map grow unbounded per isolate, clamp out-of-range indices into the
180
+ * edge buckets — the distribution's tails stay visible, the memory doesn't.
181
+ */
182
+ const MAX_EXPONENTIAL_BUCKETS = 160;
183
+
146
184
  export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpHttpMeter {
147
185
  const endpoint = options.endpoint;
148
186
  const resourceAttributes = options.resourceAttributes;
@@ -206,6 +244,7 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
206
244
  const entry: MetricEntry = { kind };
207
245
  if (kind === "counter") entry.counter = new Map();
208
246
  else if (kind === "gauge") entry.gauge = new Map();
247
+ else if (kind === "expHistogram") entry.expHistogram = new Map();
209
248
  else entry.histogram = new Map();
210
249
  metrics.set(name, entry);
211
250
  return entry;
@@ -217,6 +256,7 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
217
256
  if (entry.counter) n += entry.counter.size;
218
257
  if (entry.gauge) n += entry.gauge.size;
219
258
  if (entry.histogram) n += entry.histogram.size;
259
+ if (entry.expHistogram) n += entry.expHistogram.size;
220
260
  }
221
261
  return n;
222
262
  }
@@ -263,6 +303,10 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
263
303
  }
264
304
 
265
305
  function histogramRecord(name: string, value: number, labels?: Labels) {
306
+ if (metricMetadata[name]?.aggregation === "exponential") {
307
+ expHistogramRecord(name, value, labels);
308
+ return;
309
+ }
266
310
  const { entry: existing, isNewName } = checkAdmissibility(name, "histogram");
267
311
  if (existing === null && !isNewName) return; // kind mismatch
268
312
  const key = attrKey(labels);
@@ -301,6 +345,61 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
301
345
  point.bucketCounts[bucketIdx] += 1;
302
346
  }
303
347
 
348
+ function expHistogramRecord(name: string, value: number, labels?: Labels) {
349
+ const { entry: existing, isNewName } = checkAdmissibility(name, "expHistogram");
350
+ if (existing === null && !isNewName) return; // kind mismatch
351
+ const key = attrKey(labels);
352
+ const isNewDatapoint = !existing?.expHistogram?.has(key);
353
+ if (isNewDatapoint && pendingDatapointCount() >= maxBuffer) {
354
+ onError?.("overflow", new Error(`metric buffer at cap (${maxBuffer}) — dropping "${name}"`));
355
+ return;
356
+ }
357
+ const entry = existing ?? materializeEntry(name, "expHistogram");
358
+ if (!entry.expHistogram) return;
359
+ const scale = metricMetadata[name]?.scale ?? DEFAULT_EXPONENTIAL_SCALE;
360
+ entry.scale = scale;
361
+ let point = entry.expHistogram.get(key);
362
+ if (!point) {
363
+ point = {
364
+ count: 0,
365
+ sum: 0,
366
+ min: Number.POSITIVE_INFINITY,
367
+ max: Number.NEGATIVE_INFINITY,
368
+ zeroCount: 0,
369
+ buckets: new Map(),
370
+ attrs: labels ? { ...labels } : {},
371
+ startTimeUnixNano: msToNs(isolateStartMs),
372
+ };
373
+ entry.expHistogram.set(key, point);
374
+ }
375
+ point.count += 1;
376
+ point.sum += value;
377
+ if (value < point.min) point.min = value;
378
+ if (value > point.max) point.max = value;
379
+
380
+ // Non-positive values have no base-2 bucket. Per the OTel spec zero goes to
381
+ // `zeroCount`; we send negatives there too rather than emit a `negative`
382
+ // range no metric of ours can produce.
383
+ if (value <= 0) {
384
+ point.zeroCount += 1;
385
+ return;
386
+ }
387
+
388
+ // Spec mapping for base 2^(2^-scale): bucket `i` covers (base^i, base^(i+1)],
389
+ // i.e. upper-inclusive, so an exact power of the base lands in the LOWER
390
+ // bucket. Hence ceil(...) - 1 and not floor(...).
391
+ let index = Math.ceil(Math.log2(value) * 2 ** scale) - 1;
392
+ // Clamp into the existing range once the series is at its bucket budget —
393
+ // see MAX_EXPONENTIAL_BUCKETS.
394
+ if (!point.buckets.has(index) && point.buckets.size >= MAX_EXPONENTIAL_BUCKETS) {
395
+ const indices = [...point.buckets.keys()];
396
+ const lo = Math.min(...indices);
397
+ const hi = Math.max(...indices);
398
+ index = index < lo ? lo : hi;
399
+ }
400
+ point.buckets.set(index, (point.buckets.get(index) ?? 0) + 1);
401
+ }
402
+
304
403
  async function doFlush(): Promise<void> {
305
404
  if (metrics.size === 0) return;
306
405
 
@@ -410,7 +509,7 @@ interface SerializeOpts {
410
509
  scopeVersion: string;
411
510
  histogramBounds: number[];
412
511
  flushAtNs: string;
413
- metricMetadata: Record<string, { description?: string; unit?: string }>;
512
+ metricMetadata: Record<string, MetricMetadata>;
414
513
  }
415
514
 
416
515
  function serializeOtlp(
@@ -482,6 +581,44 @@ function serializeOtlp(
482
581
  dataPoints,
483
582
  },
484
583
  });
584
+ } else if (entry.kind === "expHistogram" && entry.expHistogram) {
585
+ const dataPoints: unknown[] = [];
586
+ for (const point of entry.expHistogram.values()) {
587
+ // Densify the sparse index->count map into OTLP's
588
+ // `positive: { offset, bucketCounts }`: counts run contiguously from
589
+ // `offset`, so gaps between populated indices must be filled with 0.
590
+ const indices = [...point.buckets.keys()].sort((a, b) => a - b);
591
+ const lo = indices[0] ?? 0;
592
+ const hi = indices[indices.length - 1] ?? -1;
593
+ const bucketCounts: string[] = [];
594
+ for (let i = lo; i <= hi; i++) bucketCounts.push(String(point.buckets.get(i) ?? 0));
595
+ dataPoints.push({
596
+ attributes: attrsToOtlp(point.attrs),
597
+ startTimeUnixNano: point.startTimeUnixNano,
598
+ timeUnixNano: opts.flushAtNs,
599
+ count: String(point.count),
600
+ sum: point.sum,
601
+ min: point.min === Number.POSITIVE_INFINITY ? 0 : point.min,
602
+ max: point.max === Number.NEGATIVE_INFINITY ? 0 : point.max,
603
+ scale: entry.scale ?? 0,
604
+ zeroCount: String(point.zeroCount),
605
+ // OTLP: bucketCounts[i] counts values in
606
+ // (base^(offset+i), base^(offset+i+1)] — the same half-open interval
607
+ // our record-time index uses, so offset is the first index as-is.
608
+ positive: { offset: lo, bucketCounts },
609
+ // No `negative` range: every metric on this path is non-negative,
610
+ // and sub-zero observations are folded into zeroCount at record time.
611
+ });
612
+ }
613
+ otlpMetrics.push({
614
+ name,
615
+ description,
616
+ unit,
617
+ exponentialHistogram: {
618
+ aggregationTemporality: 2, // CUMULATIVE
619
+ dataPoints,
620
+ },
621
+ });
485
622
  }
486
623
  }
487
624