@decocms/blocks 7.63.0 → 7.63.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/middleware/observability.ts +25 -21
- package/src/sdk/otelHttpMeter.test.ts +45 -115
- package/src/sdk/otelHttpMeter.ts +24 -138
package/package.json
CHANGED
|
@@ -283,8 +283,7 @@ export const MetricNames = {
|
|
|
283
283
|
// the metric name already provides the deco.cache.* namespace.
|
|
284
284
|
CACHE_REQUESTS: "deco.cache.requests",
|
|
285
285
|
// Size in bytes of the values moving through a cache, dimensioned by `op`.
|
|
286
|
-
//
|
|
287
|
-
// why an explicit-bounds histogram is the wrong shape here.
|
|
286
|
+
// Bucketed in decades of bytes — see CACHE_SIZE_BUCKET_BOUNDARIES_BYTES.
|
|
288
287
|
CACHE_SIZE: "deco.cache.size",
|
|
289
288
|
RESOLVE_DURATION: "deco.cms.resolve.duration",
|
|
290
289
|
LOADER_DURATION: "deco.loader.duration",
|
|
@@ -327,6 +326,29 @@ export const DURATION_BUCKET_BOUNDARIES_SECONDS: readonly number[] = [
|
|
|
327
326
|
10,
|
|
328
327
|
];
|
|
329
328
|
|
|
329
|
+
/**
|
|
330
|
+
* Bucket boundaries, in BYTES, for {@link MetricNames.CACHE_SIZE}.
|
|
331
|
+
*
|
|
332
|
+
* Decades, matching the `http.server.response.body.size` histogram already
|
|
333
|
+
* flowing through the same ingestor — a size distribution is read in orders of
|
|
334
|
+
* magnitude, not in linear steps, and reusing the house convention means the
|
|
335
|
+
* two byte histograms are directly comparable.
|
|
336
|
+
*
|
|
337
|
+
* The range is bounded on both ends by the loader cache itself: entries are
|
|
338
|
+
* floored at `MIN_ENTRY_BYTES` (512 B) and the store is capped at 32 MB, which
|
|
339
|
+
* is why the last bound is the cap rather than another decade — the top bucket
|
|
340
|
+
* then reads as "single entry at/near the whole cache budget", which is the
|
|
341
|
+
* thing worth alerting on.
|
|
342
|
+
*/
|
|
343
|
+
export const CACHE_SIZE_BUCKET_BOUNDARIES_BYTES: readonly number[] = [
|
|
344
|
+
1_000,
|
|
345
|
+
10_000,
|
|
346
|
+
100_000,
|
|
347
|
+
1_000_000,
|
|
348
|
+
10_000_000,
|
|
349
|
+
33_554_432,
|
|
350
|
+
];
|
|
351
|
+
|
|
330
352
|
/**
|
|
331
353
|
* Per-metric metadata emitted in the OTLP payload's `description` and
|
|
332
354
|
* `unit` fields. Durations are normalized to **seconds at the source** (OTel
|
|
@@ -347,16 +369,6 @@ export const METRIC_METADATA: Record<
|
|
|
347
369
|
description: string;
|
|
348
370
|
unit: string;
|
|
349
371
|
boundaries?: readonly number[];
|
|
350
|
-
/**
|
|
351
|
-
* Histogram aggregation. Omitted means `"explicit"` — bucket into
|
|
352
|
-
* `boundaries`. `"exponential"` selects an OTel base-2 exponential
|
|
353
|
-
* histogram at {@link scale}, where bucket `i` covers
|
|
354
|
-
* `(2^(2^-scale))^i .. ^(i+1)]`. Adapters that don't implement it are free
|
|
355
|
-
* to keep treating the metric as an ordinary histogram.
|
|
356
|
-
*/
|
|
357
|
-
aggregation?: "explicit" | "exponential";
|
|
358
|
-
/** Exponential-histogram scale. Fixed per metric; adapters never rescale. */
|
|
359
|
-
scale?: number;
|
|
360
372
|
}
|
|
361
373
|
> = {
|
|
362
374
|
[MetricNames.HTTP_SERVER_REQUEST_DURATION]: {
|
|
@@ -376,15 +388,7 @@ export const METRIC_METADATA: Record<
|
|
|
376
388
|
[MetricNames.CACHE_SIZE]: {
|
|
377
389
|
description: "Size in bytes of values written to / read from a cache, dimensioned by op.",
|
|
378
390
|
unit: "By",
|
|
379
|
-
|
|
380
|
-
// cachedLoader floor) to the 32 MB cap — five decades. Any fixed bound set
|
|
381
|
-
// wide enough for the top is useless at the bottom.
|
|
382
|
-
aggregation: "exponential",
|
|
383
|
-
// scale -1 => base 4, so buckets land on 512 B · 2 K · 8 K · 32 K · 128 K ·
|
|
384
|
-
// 512 K · 2 M · 8 M · 32 M: ~9 buckets over the real range, ~1.7 per
|
|
385
|
-
// decade. scale 0 (base 2) would double the series count without saying
|
|
386
|
-
// anything new about a size distribution.
|
|
387
|
-
scale: -1,
|
|
391
|
+
boundaries: CACHE_SIZE_BUCKET_BOUNDARIES_BYTES,
|
|
388
392
|
},
|
|
389
393
|
[MetricNames.RESOLVE_DURATION]: {
|
|
390
394
|
description: "Duration of `deco.cms.resolvePage` — CMS route to block tree resolution.",
|
|
@@ -65,12 +65,7 @@ function buildAdapter(
|
|
|
65
65
|
histogramBounds?: number[];
|
|
66
66
|
metricMetadata?: Record<
|
|
67
67
|
string,
|
|
68
|
-
{
|
|
69
|
-
description?: string;
|
|
70
|
-
unit?: string;
|
|
71
|
-
aggregation?: "explicit" | "exponential";
|
|
72
|
-
scale?: number;
|
|
73
|
-
}
|
|
68
|
+
{ description?: string; unit?: string; boundaries?: readonly number[] }
|
|
74
69
|
>;
|
|
75
70
|
} = {},
|
|
76
71
|
) {
|
|
@@ -302,46 +297,34 @@ describe("createOtlpHttpMeterAdapter — buffer + flush", () => {
|
|
|
302
297
|
});
|
|
303
298
|
|
|
304
299
|
// ---------------------------------------------------------------------------
|
|
305
|
-
//
|
|
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
|
|