@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
|
@@ -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
|
-
{
|
|
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
|
package/src/sdk/cachedLoader.ts
CHANGED
|
@@ -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
|
-
|
|
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(
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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(
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
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
|
+
});
|
package/src/sdk/observability.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/sdk/otelHttpMeter.ts
CHANGED
|
@@ -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,
|
|
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,
|
|
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
|
|