@decocms/blocks 7.62.5 → 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
|
@@ -282,6 +282,9 @@ 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
|
+
// Bucketed in decades of bytes — see CACHE_SIZE_BUCKET_BOUNDARIES_BYTES.
|
|
287
|
+
CACHE_SIZE: "deco.cache.size",
|
|
285
288
|
RESOLVE_DURATION: "deco.cms.resolve.duration",
|
|
286
289
|
LOADER_DURATION: "deco.loader.duration",
|
|
287
290
|
LOADER_ERRORS: "deco.loader.errors",
|
|
@@ -323,6 +326,29 @@ export const DURATION_BUCKET_BOUNDARIES_SECONDS: readonly number[] = [
|
|
|
323
326
|
10,
|
|
324
327
|
];
|
|
325
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
|
+
|
|
326
352
|
/**
|
|
327
353
|
* Per-metric metadata emitted in the OTLP payload's `description` and
|
|
328
354
|
* `unit` fields. Durations are normalized to **seconds at the source** (OTel
|
|
@@ -339,7 +365,11 @@ export const DURATION_BUCKET_BOUNDARIES_SECONDS: readonly number[] = [
|
|
|
339
365
|
*/
|
|
340
366
|
export const METRIC_METADATA: Record<
|
|
341
367
|
string,
|
|
342
|
-
{
|
|
368
|
+
{
|
|
369
|
+
description: string;
|
|
370
|
+
unit: string;
|
|
371
|
+
boundaries?: readonly number[];
|
|
372
|
+
}
|
|
343
373
|
> = {
|
|
344
374
|
[MetricNames.HTTP_SERVER_REQUEST_DURATION]: {
|
|
345
375
|
description: "Duration of HTTP server requests handled at the Worker entry point.",
|
|
@@ -355,6 +385,11 @@ export const METRIC_METADATA: Record<
|
|
|
355
385
|
description: "Cache lookups, dimensioned by status (hit/stale/miss/bypass).",
|
|
356
386
|
unit: "{request}",
|
|
357
387
|
},
|
|
388
|
+
[MetricNames.CACHE_SIZE]: {
|
|
389
|
+
description: "Size in bytes of values written to / read from a cache, dimensioned by op.",
|
|
390
|
+
unit: "By",
|
|
391
|
+
boundaries: CACHE_SIZE_BUCKET_BOUNDARIES_BYTES,
|
|
392
|
+
},
|
|
358
393
|
[MetricNames.RESOLVE_DURATION]: {
|
|
359
394
|
description: "Duration of `deco.cms.resolvePage` — CMS route to block tree resolution.",
|
|
360
395
|
unit: "s",
|
|
@@ -590,6 +625,22 @@ export function recordCacheMetric(
|
|
|
590
625
|
m.counterInc(MetricNames.CACHE_REQUESTS, 1, labels);
|
|
591
626
|
}
|
|
592
627
|
|
|
628
|
+
/**
|
|
629
|
+
* Record the size of a value moving through a cache, in BYTES — not seconds,
|
|
630
|
+
* so unlike the duration helpers this one does not divide.
|
|
631
|
+
*
|
|
632
|
+
* `profile` deliberately reuses the label key {@link recordCacheMetric} already
|
|
633
|
+
* uses for the `cachedLoader` layer (there it carries the loader name), so
|
|
634
|
+
* `deco.cache.size` joins `deco.cache.requests{layer="cachedLoader"}` on
|
|
635
|
+
* `profile` with no renaming in the query.
|
|
636
|
+
*
|
|
637
|
+
* Only `op: "set"` is emitted today; the label exists so a future read-side
|
|
638
|
+
* measurement lands on the same series without a breaking dashboard change.
|
|
639
|
+
*/
|
|
640
|
+
export function recordCacheSizeMetric(bytes: number, profile: string, op: "set" | "get" = "set") {
|
|
641
|
+
getState().meter?.histogramRecord?.(MetricNames.CACHE_SIZE, bytes, { op, profile });
|
|
642
|
+
}
|
|
643
|
+
|
|
593
644
|
/**
|
|
594
645
|
* Labels for the outbound commerce sample recorded on
|
|
595
646
|
* `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,10 @@ 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
|
+
{ description?: string; unit?: string; boundaries?: readonly number[] }
|
|
69
|
+
>;
|
|
66
70
|
} = {},
|
|
67
71
|
) {
|
|
68
72
|
return createOtlpHttpMeterAdapter({
|
|
@@ -78,6 +82,7 @@ function buildAdapter(
|
|
|
78
82
|
nowMs: overrides.nowMs,
|
|
79
83
|
onError: overrides.onError,
|
|
80
84
|
histogramBounds: overrides.histogramBounds,
|
|
85
|
+
metricMetadata: overrides.metricMetadata,
|
|
81
86
|
});
|
|
82
87
|
}
|
|
83
88
|
|
|
@@ -290,3 +295,101 @@ describe("createOtlpHttpMeterAdapter — buffer + flush", () => {
|
|
|
290
295
|
expect(slow).toHaveBeenCalledTimes(1);
|
|
291
296
|
});
|
|
292
297
|
});
|
|
298
|
+
|
|
299
|
+
// ---------------------------------------------------------------------------
|
|
300
|
+
// Per-metric bucket boundaries
|
|
301
|
+
// ---------------------------------------------------------------------------
|
|
302
|
+
|
|
303
|
+
interface HistoMetric {
|
|
304
|
+
name: string;
|
|
305
|
+
unit?: string;
|
|
306
|
+
histogram?: {
|
|
307
|
+
dataPoints: Array<{
|
|
308
|
+
attributes: Array<{ key: string; value: Record<string, unknown> }>;
|
|
309
|
+
count: string;
|
|
310
|
+
sum: number;
|
|
311
|
+
bucketCounts: string[];
|
|
312
|
+
explicitBounds: number[];
|
|
313
|
+
}>;
|
|
314
|
+
};
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
function histoMetrics(calls: Array<{ init?: RequestInit }>): HistoMetric[] {
|
|
318
|
+
const payload = JSON.parse(calls[0].init!.body as string) as {
|
|
319
|
+
resourceMetrics: Array<{ scopeMetrics: Array<{ metrics: HistoMetric[] }> }>;
|
|
320
|
+
};
|
|
321
|
+
return payload.resourceMetrics[0].scopeMetrics[0].metrics;
|
|
322
|
+
}
|
|
323
|
+
|
|
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", () => {
|
|
328
|
+
beforeEach(() => {
|
|
329
|
+
vi.useFakeTimers();
|
|
330
|
+
vi.setSystemTime(new Date("2026-05-18T16:00:00.000Z"));
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
afterEach(() => {
|
|
334
|
+
vi.useRealTimers();
|
|
335
|
+
vi.restoreAllMocks();
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
it("buckets a metric against its own boundaries, not the adapter default", async () => {
|
|
339
|
+
const { impl, calls } = captureFetch();
|
|
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
|
+
});
|
|
347
|
+
|
|
348
|
+
const labels = { op: "set", profile: "productList" };
|
|
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)
|
|
352
|
+
|
|
353
|
+
await meter.flush();
|
|
354
|
+
|
|
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"]);
|
|
358
|
+
expect(dp.count).toBe("3");
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
it("emits the bounds the counts were filed against, per metric, in one flush", async () => {
|
|
362
|
+
const { impl, calls } = captureFetch();
|
|
363
|
+
const meter = buildAdapter({
|
|
364
|
+
fetchImpl: impl,
|
|
365
|
+
histogramBounds: [5, 10, 25],
|
|
366
|
+
metricMetadata: { "deco.cache.size": { unit: "By", boundaries: BYTE_BOUNDS } },
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// Two histograms with different units in the SAME adapter — the bug was
|
|
370
|
+
// that both serialized with one shared array.
|
|
371
|
+
meter.histogramRecord("deco.cache.size", 512, { op: "set" });
|
|
372
|
+
meter.histogramRecord("deco.loader.duration", 7, { name: "x" });
|
|
373
|
+
|
|
374
|
+
await meter.flush();
|
|
375
|
+
|
|
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
|
+
]);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
it("falls back to the adapter default when a metric declares no boundaries", async () => {
|
|
384
|
+
const { impl, calls } = captureFetch();
|
|
385
|
+
const meter = buildAdapter({ fetchImpl: impl, histogramBounds: [10, 100] });
|
|
386
|
+
|
|
387
|
+
meter.histogramRecord("deco.loader.duration", 50, { name: "x" });
|
|
388
|
+
|
|
389
|
+
await meter.flush();
|
|
390
|
+
|
|
391
|
+
const dp = histoMetrics(calls)[0].histogram!.dataPoints[0];
|
|
392
|
+
expect(dp.explicitBounds).toEqual([10, 100]);
|
|
393
|
+
expect(dp.bucketCounts).toEqual(["0", "1", "0"]);
|
|
394
|
+
});
|
|
395
|
+
});
|
package/src/sdk/otelHttpMeter.ts
CHANGED
|
@@ -84,8 +84,20 @@ 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
|
+
* `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.
|
|
87
92
|
*/
|
|
88
|
-
metricMetadata?: Record<string,
|
|
93
|
+
metricMetadata?: Record<string, MetricMetadata>;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export interface MetricMetadata {
|
|
97
|
+
description?: string;
|
|
98
|
+
unit?: string;
|
|
99
|
+
/** Explicit bucket bounds for THIS metric, in the metric's own unit. */
|
|
100
|
+
boundaries?: readonly number[];
|
|
89
101
|
}
|
|
90
102
|
|
|
91
103
|
export interface OtlpHttpMeter extends MeterAdapter {
|
|
@@ -130,6 +142,13 @@ interface MetricEntry {
|
|
|
130
142
|
counter?: Map<string, CounterPoint>;
|
|
131
143
|
gauge?: Map<string, GaugePoint>;
|
|
132
144
|
histogram?: Map<string, HistogramPoint>;
|
|
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[];
|
|
133
152
|
}
|
|
134
153
|
|
|
135
154
|
// ---------------------------------------------------------------------------
|
|
@@ -273,6 +292,8 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
|
|
|
273
292
|
}
|
|
274
293
|
const entry = existing ?? materializeEntry(name, "histogram");
|
|
275
294
|
if (!entry.histogram) return;
|
|
295
|
+
const bounds = entry.bounds ?? metricMetadata[name]?.boundaries ?? histogramBounds;
|
|
296
|
+
entry.bounds = bounds;
|
|
276
297
|
let point = entry.histogram.get(key);
|
|
277
298
|
if (!point) {
|
|
278
299
|
point = {
|
|
@@ -280,7 +301,7 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
|
|
|
280
301
|
sum: 0,
|
|
281
302
|
min: Number.POSITIVE_INFINITY,
|
|
282
303
|
max: Number.NEGATIVE_INFINITY,
|
|
283
|
-
bucketCounts: new Array(
|
|
304
|
+
bucketCounts: new Array(bounds.length + 1).fill(0),
|
|
284
305
|
attrs: labels ? { ...labels } : {},
|
|
285
306
|
startTimeUnixNano: msToNs(isolateStartMs),
|
|
286
307
|
};
|
|
@@ -290,10 +311,10 @@ export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpH
|
|
|
290
311
|
point.sum += value;
|
|
291
312
|
if (value < point.min) point.min = value;
|
|
292
313
|
if (value > point.max) point.max = value;
|
|
293
|
-
// Locate bucket.
|
|
294
|
-
let bucketIdx =
|
|
295
|
-
for (let i = 0; i <
|
|
296
|
-
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]) {
|
|
297
318
|
bucketIdx = i;
|
|
298
319
|
break;
|
|
299
320
|
}
|
|
@@ -410,7 +431,7 @@ interface SerializeOpts {
|
|
|
410
431
|
scopeVersion: string;
|
|
411
432
|
histogramBounds: number[];
|
|
412
433
|
flushAtNs: string;
|
|
413
|
-
metricMetadata: Record<string,
|
|
434
|
+
metricMetadata: Record<string, MetricMetadata>;
|
|
414
435
|
}
|
|
415
436
|
|
|
416
437
|
function serializeOtlp(
|
|
@@ -470,7 +491,9 @@ function serializeOtlp(
|
|
|
470
491
|
min: point.min === Number.POSITIVE_INFINITY ? 0 : point.min,
|
|
471
492
|
max: point.max === Number.NEGATIVE_INFINITY ? 0 : point.max,
|
|
472
493
|
bucketCounts: point.bucketCounts.map((c) => String(c)),
|
|
473
|
-
|
|
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,
|
|
474
497
|
});
|
|
475
498
|
}
|
|
476
499
|
otlpMetrics.push({
|