@decocms/blocks 7.62.5 → 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.5",
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,
@@ -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,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