@decocms/blocks 7.63.0 → 7.64.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.
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Shared SWR in-memory fetch cache for commerce API responses.
2
+ * Shared SWR fetch cache for commerce API responses.
3
3
  *
4
4
  * Provides in-flight deduplication + stale-while-revalidate + stale-if-error
5
5
  * for server-side GET requests, keyed by full URL string. Ported from the
@@ -17,8 +17,8 @@
17
17
  * tests), so there is zero cost off the instrumented path.
18
18
  *
19
19
  * **Provider isolation:** every {@link createFetchCache} call owns its own
20
- * `store`/`inflight` Maps, so VTEX / Magento / Shopify caches never collide in
21
- * a shared isolate. The `provider` string rides on the `provider` metric label so
20
+ * bounded memory tier and in-flight Map. Shared storage keys include the
21
+ * provider name, so VTEX / Magento / Shopify entries never collide. The `provider` string rides on the `provider` metric label so
22
22
  * dashboards can slice hit ratio per backend.
23
23
  *
24
24
  * Note on why the SWR cache — not `createInstrumentedFetch` — must emit the
@@ -28,6 +28,7 @@
28
28
  */
29
29
 
30
30
  import { type CacheDecision, recordCacheMetric } from "../middleware/observability";
31
+ import { cacheBackground, createCacheStore, getCacheStorageContext } from "./cacheStorage";
31
32
 
32
33
  /** Fresh-TTL (ms) by HTTP status class. See {@link FetchCacheConfig}. */
33
34
  export interface FreshTtlByStatus {
@@ -129,7 +130,7 @@ function withTimeout<T>(work: Promise<T>, ms: number, label: string): Promise<T>
129
130
  export function createFetchCache(config: FetchCacheConfig): FetchCache {
130
131
  const { provider, maxEntries, freshTtlMs, staleIfErrorMs, inflightBackstopMs } = config;
131
132
 
132
- const store = new Map<string, CacheEntry>();
133
+ const store = createCacheStore<CacheEntry>(`fetch:${provider}`, maxEntries);
133
134
  const inflight = new Map<string, Promise<CacheEntry>>();
134
135
 
135
136
  function freshTtlForStatus(status: number): number {
@@ -139,12 +140,6 @@ export function createFetchCache(config: FetchCacheConfig): FetchCache {
139
140
  return 0;
140
141
  }
141
142
 
142
- function evictIfNeeded() {
143
- // ponytail: Map preserves insertion order ≈ createdAt order — holds as long
144
- // as updates always re-insert via store.set(key, fresh), never mutate in-place.
145
- while (store.size > maxEntries) store.delete(store.keys().next().value!);
146
- }
147
-
148
143
  // Single attempt on purpose — the underlying fetch (resilience layer) owns
149
144
  // retries/backoff/breaker. Retrying here would multiply upstream calls and
150
145
  // re-enter the breaker too fast.
@@ -165,13 +160,16 @@ export function createFetchCache(config: FetchCacheConfig): FetchCache {
165
160
  recordCacheMetric(decision !== "MISS", undefined, decision, "swr", provider);
166
161
  }
167
162
 
168
- function fetchWithCache<T>(
163
+ async function fetchWithCache<T>(
169
164
  cacheKey: string,
170
165
  doFetch: () => Promise<Response>,
171
166
  opts?: FetchCacheOptions,
172
167
  ): Promise<T | null> {
168
+ cacheKey = store.key(cacheKey);
169
+ const entry = getCacheStorageContext()?.storage
170
+ ? await store.read(cacheKey)
171
+ : store.get(cacheKey);
173
172
  const now = Date.now();
174
- const entry = store.get(cacheKey);
175
173
 
176
174
  if (entry) {
177
175
  const maxAge = opts?.ttl ?? freshTtlForStatus(entry.status);
@@ -194,25 +192,31 @@ export function createFetchCache(config: FetchCacheConfig): FetchCache {
194
192
  if (!entry.refreshing) {
195
193
  entry.refreshing = true;
196
194
  // Background refresh: no retry — stale data is already being served.
197
- withTimeout(
198
- executeFetch(cacheKey, doFetch),
199
- inflightBackstopMs,
200
- `fetchCache stale-refresh ${cacheKey}`,
201
- )
202
- .then((fresh) => {
203
- const ttl = opts?.ttl ?? freshTtlForStatus(fresh.status);
204
- const existingWasSuccess = entry.status >= 200 && entry.status < 300;
205
- const freshIsError = fresh.status >= 400;
206
- const wouldDowngrade = existingWasSuccess && freshIsError;
207
- if (ttl > 0 && !wouldDowngrade) {
208
- store.set(cacheKey, fresh);
209
- } else {
195
+ cacheBackground(
196
+ withTimeout(
197
+ executeFetch(cacheKey, doFetch),
198
+ inflightBackstopMs,
199
+ `fetchCache stale-refresh ${cacheKey}`,
200
+ )
201
+ .then((fresh) => {
202
+ const ttl = opts?.ttl ?? freshTtlForStatus(fresh.status);
203
+ const existingWasSuccess = entry.status >= 200 && entry.status < 300;
204
+ const freshIsError = fresh.status >= 400;
205
+ const wouldDowngrade = existingWasSuccess && freshIsError;
206
+ if (ttl > 0 && !wouldDowngrade) {
207
+ store.set(
208
+ cacheKey,
209
+ fresh,
210
+ fresh.createdAt + ttl + (opts?.sieMs ?? staleIfErrorMs),
211
+ );
212
+ } else {
213
+ entry.refreshing = false;
214
+ }
215
+ })
216
+ .catch(() => {
210
217
  entry.refreshing = false;
211
- }
212
- })
213
- .catch(() => {
214
- entry.refreshing = false;
215
- });
218
+ }),
219
+ );
216
220
  }
217
221
  // Serve last-good within the SIE window: this is the stale-while-
218
222
  // revalidate serve.
@@ -242,8 +246,7 @@ export function createFetchCache(config: FetchCacheConfig): FetchCache {
242
246
  .then((fresh) => {
243
247
  const ttl = opts?.ttl ?? freshTtlForStatus(fresh.status);
244
248
  if (ttl > 0) {
245
- store.set(cacheKey, fresh);
246
- evictIfNeeded();
249
+ store.set(cacheKey, fresh, fresh.createdAt + ttl + (opts?.sieMs ?? staleIfErrorMs));
247
250
  }
248
251
  return fresh;
249
252
  })
@@ -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