@decocms/blocks 7.63.0 → 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.63.0",
3
+ "version": "7.63.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=24"
@@ -283,8 +283,7 @@ export const MetricNames = {
283
283
  // the metric name already provides the deco.cache.* namespace.
284
284
  CACHE_REQUESTS: "deco.cache.requests",
285
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.
286
+ // Bucketed in decades of bytes — see CACHE_SIZE_BUCKET_BOUNDARIES_BYTES.
288
287
  CACHE_SIZE: "deco.cache.size",
289
288
  RESOLVE_DURATION: "deco.cms.resolve.duration",
290
289
  LOADER_DURATION: "deco.loader.duration",
@@ -327,6 +326,29 @@ export const DURATION_BUCKET_BOUNDARIES_SECONDS: readonly number[] = [
327
326
  10,
328
327
  ];
329
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
+
330
352
  /**
331
353
  * Per-metric metadata emitted in the OTLP payload's `description` and
332
354
  * `unit` fields. Durations are normalized to **seconds at the source** (OTel
@@ -347,16 +369,6 @@ export const METRIC_METADATA: Record<
347
369
  description: string;
348
370
  unit: string;
349
371
  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
372
  }
361
373
  > = {
362
374
  [MetricNames.HTTP_SERVER_REQUEST_DURATION]: {
@@ -376,15 +388,7 @@ export const METRIC_METADATA: Record<
376
388
  [MetricNames.CACHE_SIZE]: {
377
389
  description: "Size in bytes of values written to / read from a cache, dimensioned by op.",
378
390
  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,
391
+ boundaries: CACHE_SIZE_BUCKET_BOUNDARIES_BYTES,
388
392
  },
389
393
  [MetricNames.RESOLVE_DURATION]: {
390
394
  description: "Duration of `deco.cms.resolvePage` — CMS route to block tree resolution.",
@@ -65,12 +65,7 @@ function buildAdapter(
65
65
  histogramBounds?: number[];
66
66
  metricMetadata?: Record<
67
67
  string,
68
- {
69
- description?: string;
70
- unit?: string;
71
- aggregation?: "explicit" | "exponential";
72
- scale?: number;
73
- }
68
+ { description?: string; unit?: string; boundaries?: readonly number[] }
74
69
  >;
75
70
  } = {},
76
71
  ) {
@@ -302,46 +297,34 @@ describe("createOtlpHttpMeterAdapter — buffer + flush", () => {
302
297
  });
303
298
 
304
299
  // ---------------------------------------------------------------------------
305
- // Exponential histograms (deco.cache.size)
300
+ // Per-metric bucket boundaries
306
301
  // ---------------------------------------------------------------------------
307
302
 
308
- interface ExpHistogramMetric {
303
+ interface HistoMetric {
309
304
  name: string;
310
305
  unit?: string;
311
- histogram?: unknown;
312
- exponentialHistogram?: {
313
- aggregationTemporality: number;
306
+ histogram?: {
314
307
  dataPoints: Array<{
315
308
  attributes: Array<{ key: string; value: Record<string, unknown> }>;
316
309
  count: string;
317
310
  sum: number;
318
- min: number;
319
- max: number;
320
- scale: number;
321
- zeroCount: string;
322
- positive: { offset: number; bucketCounts: string[] };
311
+ bucketCounts: string[];
312
+ explicitBounds: number[];
323
313
  }>;
324
314
  };
325
315
  }
326
316
 
327
- function metricsFrom(calls: Array<{ init?: RequestInit }>): ExpHistogramMetric[] {
317
+ function histoMetrics(calls: Array<{ init?: RequestInit }>): HistoMetric[] {
328
318
  const payload = JSON.parse(calls[0].init!.body as string) as {
329
- resourceMetrics: Array<{ scopeMetrics: Array<{ metrics: ExpHistogramMetric[] }> }>;
319
+ resourceMetrics: Array<{ scopeMetrics: Array<{ metrics: HistoMetric[] }> }>;
330
320
  };
331
321
  return payload.resourceMetrics[0].scopeMetrics[0].metrics;
332
322
  }
333
323
 
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", () => {
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", () => {
345
328
  beforeEach(() => {
346
329
  vi.useFakeTimers();
347
330
  vi.setSystemTime(new Date("2026-05-18T16:00:00.000Z"));
@@ -352,114 +335,61 @@ describe("createOtlpHttpMeterAdapter — exponential histogram", () => {
352
335
  vi.restoreAllMocks();
353
336
  });
354
337
 
355
- it("buckets byte sizes at scale -1 and exports OTLP exponentialHistogram", async () => {
338
+ it("buckets a metric against its own boundaries, not the adapter default", async () => {
356
339
  const { impl, calls } = captureFetch();
357
- const meter = buildAdapter({ fetchImpl: impl, metricMetadata: CACHE_SIZE_META });
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
+ });
358
347
 
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
348
  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);
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)
367
352
 
368
353
  await meter.flush();
369
354
 
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);
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"]);
379
358
  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
359
  });
389
360
 
390
- it("fills gaps between populated buckets so offset+i stays aligned", async () => {
361
+ it("emits the bounds the counts were filed against, per metric, in one flush", async () => {
391
362
  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 });
363
+ const meter = buildAdapter({
364
+ fetchImpl: impl,
365
+ histogramBounds: [5, 10, 25],
366
+ metricMetadata: { "deco.cache.size": { unit: "By", boundaries: BYTE_BOUNDS } },
367
+ });
422
368
 
423
- meter.histogramRecord("deco.cache.size", 0, { op: "set" });
369
+ // Two histograms with different units in the SAME adapter — the bug was
370
+ // that both serialized with one shared array.
424
371
  meter.histogramRecord("deco.cache.size", 512, { op: "set" });
372
+ meter.histogramRecord("deco.loader.duration", 7, { name: "x" });
425
373
 
426
374
  await meter.flush();
427
375
 
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"]);
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
+ ]);
432
381
  });
433
382
 
434
- it("leaves histograms without exponential metadata on the explicit-bounds path", async () => {
383
+ it("falls back to the adapter default when a metric declares no boundaries", async () => {
435
384
  const { impl, calls } = captureFetch();
436
- const meter = buildAdapter({
437
- fetchImpl: impl,
438
- histogramBounds: [10, 100],
439
- metricMetadata: CACHE_SIZE_META,
440
- });
385
+ const meter = buildAdapter({ fetchImpl: impl, histogramBounds: [10, 100] });
441
386
 
442
387
  meter.histogramRecord("deco.loader.duration", 50, { name: "x" });
443
388
 
444
389
  await meter.flush();
445
390
 
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);
391
+ const dp = histoMetrics(calls)[0].histogram!.dataPoints[0];
392
+ expect(dp.explicitBounds).toEqual([10, 100]);
393
+ expect(dp.bucketCounts).toEqual(["0", "1", "0"]);
464
394
  });
465
395
  });
@@ -85,9 +85,10 @@ export interface OtlpHttpMeterOptions {
85
85
  * record time. See `MetricNames` / `METRIC_METADATA` in
86
86
  * `middleware/observability.ts`.
87
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.
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.
91
92
  */
92
93
  metricMetadata?: Record<string, MetricMetadata>;
93
94
  }
@@ -95,8 +96,8 @@ export interface OtlpHttpMeterOptions {
95
96
  export interface MetricMetadata {
96
97
  description?: string;
97
98
  unit?: string;
98
- aggregation?: "explicit" | "exponential";
99
- scale?: number;
99
+ /** Explicit bucket bounds for THIS metric, in the metric's own unit. */
100
+ boundaries?: readonly number[];
100
101
  }
101
102
 
102
103
  export interface OtlpHttpMeter extends MeterAdapter {
@@ -114,7 +115,7 @@ export interface OtlpHttpMeter extends MeterAdapter {
114
115
  // Internal buffer shapes
115
116
  // ---------------------------------------------------------------------------
116
117
 
117
- type MetricKind = "counter" | "gauge" | "histogram" | "expHistogram";
118
+ type MetricKind = "counter" | "gauge" | "histogram";
118
119
 
119
120
  interface CounterPoint {
120
121
  value: number;
@@ -136,26 +137,18 @@ interface HistogramPoint {
136
137
  startTimeUnixNano: string;
137
138
  }
138
139
 
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
-
151
140
  interface MetricEntry {
152
141
  kind: MetricKind;
153
142
  counter?: Map<string, CounterPoint>;
154
143
  gauge?: Map<string, GaugePoint>;
155
144
  histogram?: Map<string, HistogramPoint>;
156
- expHistogram?: Map<string, ExpHistogramPoint>;
157
- /** Only set for `expHistogram`, captured from metadata at first record. */
158
- scale?: number;
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[];
159
152
  }
160
153
 
161
154
  // ---------------------------------------------------------------------------
@@ -169,18 +162,6 @@ const DEFAULT_HISTOGRAM_BOUNDS = [
169
162
  5, 10, 25, 50, 75, 100, 250, 500, 1000,
170
163
  ];
171
164
 
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
-
184
165
  export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpHttpMeter {
185
166
  const endpoint = options.endpoint;
186
167
  const resourceAttributes = options.resourceAttributes;
@@ -244,7 +225,6 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
244
225
  const entry: MetricEntry = { kind };
245
226
  if (kind === "counter") entry.counter = new Map();
246
227
  else if (kind === "gauge") entry.gauge = new Map();
247
- else if (kind === "expHistogram") entry.expHistogram = new Map();
248
228
  else entry.histogram = new Map();
249
229
  metrics.set(name, entry);
250
230
  return entry;
@@ -256,7 +236,6 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
256
236
  if (entry.counter) n += entry.counter.size;
257
237
  if (entry.gauge) n += entry.gauge.size;
258
238
  if (entry.histogram) n += entry.histogram.size;
259
- if (entry.expHistogram) n += entry.expHistogram.size;
260
239
  }
261
240
  return n;
262
241
  }
@@ -303,10 +282,6 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
303
282
  }
304
283
 
305
284
  function histogramRecord(name: string, value: number, labels?: Labels) {
306
- if (metricMetadata[name]?.aggregation === "exponential") {
307
- expHistogramRecord(name, value, labels);
308
- return;
309
- }
310
285
  const { entry: existing, isNewName } = checkAdmissibility(name, "histogram");
311
286
  if (existing === null && !isNewName) return; // kind mismatch
312
287
  const key = attrKey(labels);
@@ -317,6 +292,8 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
317
292
  }
318
293
  const entry = existing ?? materializeEntry(name, "histogram");
319
294
  if (!entry.histogram) return;
295
+ const bounds = entry.bounds ?? metricMetadata[name]?.boundaries ?? histogramBounds;
296
+ entry.bounds = bounds;
320
297
  let point = entry.histogram.get(key);
321
298
  if (!point) {
322
299
  point = {
@@ -324,7 +301,7 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
324
301
  sum: 0,
325
302
  min: Number.POSITIVE_INFINITY,
326
303
  max: Number.NEGATIVE_INFINITY,
327
- bucketCounts: new Array(histogramBounds.length + 1).fill(0),
304
+ bucketCounts: new Array(bounds.length + 1).fill(0),
328
305
  attrs: labels ? { ...labels } : {},
329
306
  startTimeUnixNano: msToNs(isolateStartMs),
330
307
  };
@@ -334,10 +311,10 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
334
311
  point.sum += value;
335
312
  if (value < point.min) point.min = value;
336
313
  if (value > point.max) point.max = value;
337
- // Locate bucket. histogramBounds is small (<=20); linear scan is fine.
338
- let bucketIdx = histogramBounds.length;
339
- for (let i = 0; i < histogramBounds.length; i++) {
340
- 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]) {
341
318
  bucketIdx = i;
342
319
  break;
343
320
  }
@@ -345,61 +322,6 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
345
322
  point.bucketCounts[bucketIdx] += 1;
346
323
  }
347
324
 
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
-
403
325
  async function doFlush(): Promise<void> {
404
326
  if (metrics.size === 0) return;
405
327
 
@@ -569,7 +491,9 @@ function serializeOtlp(
569
491
  min: point.min === Number.POSITIVE_INFINITY ? 0 : point.min,
570
492
  max: point.max === Number.NEGATIVE_INFINITY ? 0 : point.max,
571
493
  bucketCounts: point.bucketCounts.map((c) => String(c)),
572
- 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,
573
497
  });
574
498
  }
575
499
  otlpMetrics.push({
@@ -581,44 +505,6 @@ function serializeOtlp(
581
505
  dataPoints,
582
506
  },
583
507
  });
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
- });
622
508
  }
623
509
  }
624
510