@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.
- package/package.json +3 -1
- package/src/cms/index.ts +3 -2
- package/src/cms/resolve.ts +33 -23
- package/src/cms/sectionLoaders.ts +56 -43
- package/src/middleware/observability.ts +25 -21
- package/src/sdk/cacheStorage.test.ts +242 -0
- package/src/sdk/cacheStorage.ts +287 -0
- package/src/sdk/cachedLoader.ts +82 -63
- package/src/sdk/fetchCache.ts +35 -32
- package/src/sdk/otelHttpMeter.test.ts +45 -115
- package/src/sdk/otelHttpMeter.ts +24 -138
- package/src/sdk/responseCache.ts +79 -0
package/src/sdk/fetchCache.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared SWR
|
|
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
|
-
*
|
|
21
|
-
*
|
|
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 =
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
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
|
-
//
|
|
300
|
+
// Per-metric bucket boundaries
|
|
306
301
|
// ---------------------------------------------------------------------------
|
|
307
302
|
|
|
308
|
-
interface
|
|
303
|
+
interface HistoMetric {
|
|
309
304
|
name: string;
|
|
310
305
|
unit?: string;
|
|
311
|
-
histogram?:
|
|
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
|
-
|
|
319
|
-
|
|
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
|
|
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:
|
|
319
|
+
resourceMetrics: Array<{ scopeMetrics: Array<{ metrics: HistoMetric[] }> }>;
|
|
330
320
|
};
|
|
331
321
|
return payload.resourceMetrics[0].scopeMetrics[0].metrics;
|
|
332
322
|
}
|
|
333
323
|
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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
|
|
338
|
+
it("buckets a metric against its own boundaries, not the adapter default", async () => {
|
|
356
339
|
const { impl, calls } = captureFetch();
|
|
357
|
-
const meter = buildAdapter({
|
|
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",
|
|
366
|
-
meter.histogramRecord("deco.cache.size",
|
|
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
|
|
371
|
-
expect(
|
|
372
|
-
expect(
|
|
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("
|
|
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({
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
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
|
-
|
|
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
|
|
429
|
-
expect(
|
|
430
|
-
expect(
|
|
431
|
-
|
|
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("
|
|
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
|
|
447
|
-
|
|
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
|
});
|
package/src/sdk/otelHttpMeter.ts
CHANGED
|
@@ -85,9 +85,10 @@ export interface OtlpHttpMeterOptions {
|
|
|
85
85
|
* record time. See `MetricNames` / `METRIC_METADATA` in
|
|
86
86
|
* `middleware/observability.ts`.
|
|
87
87
|
*
|
|
88
|
-
* `
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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
|
-
|
|
99
|
-
|
|
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"
|
|
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
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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(
|
|
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.
|
|
338
|
-
let bucketIdx =
|
|
339
|
-
for (let i = 0; i <
|
|
340
|
-
if (value <=
|
|
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
|
-
|
|
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
|
|