@decocms/blocks 7.0.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 +97 -0
- package/src/cms/applySectionConventions.ts +128 -0
- package/src/cms/blockSource.test.ts +67 -0
- package/src/cms/blockSource.ts +124 -0
- package/src/cms/index.ts +104 -0
- package/src/cms/layoutCacheRace.test.ts +146 -0
- package/src/cms/loadDecofileDirectory.test.ts +52 -0
- package/src/cms/loadDecofileDirectory.ts +48 -0
- package/src/cms/loader.test.ts +184 -0
- package/src/cms/loader.ts +284 -0
- package/src/cms/registry.test.ts +118 -0
- package/src/cms/registry.ts +252 -0
- package/src/cms/resolve.test.ts +547 -0
- package/src/cms/resolve.ts +2003 -0
- package/src/cms/schema.ts +993 -0
- package/src/cms/sectionLoaders.test.ts +409 -0
- package/src/cms/sectionLoaders.ts +562 -0
- package/src/cms/sectionMixins.test.ts +163 -0
- package/src/cms/sectionMixins.ts +153 -0
- package/src/hooks/LazySection.tsx +121 -0
- package/src/hooks/LiveControls.tsx +122 -0
- package/src/hooks/RenderSection.test.tsx +81 -0
- package/src/hooks/RenderSection.tsx +91 -0
- package/src/hooks/SectionErrorFallback.tsx +97 -0
- package/src/hooks/index.ts +4 -0
- package/src/index.ts +8 -0
- package/src/matchers/builtins.test.ts +251 -0
- package/src/matchers/builtins.ts +437 -0
- package/src/matchers/countryNames.ts +104 -0
- package/src/matchers/override.test.ts +205 -0
- package/src/matchers/override.ts +136 -0
- package/src/matchers/posthog.ts +154 -0
- package/src/middleware/decoState.ts +55 -0
- package/src/middleware/healthMetrics.ts +133 -0
- package/src/middleware/hydrationContext.test.ts +61 -0
- package/src/middleware/hydrationContext.ts +79 -0
- package/src/middleware/index.ts +88 -0
- package/src/middleware/liveness.ts +21 -0
- package/src/middleware/observability.test.ts +238 -0
- package/src/middleware/observability.ts +620 -0
- package/src/middleware/validateSection.test.ts +147 -0
- package/src/middleware/validateSection.ts +100 -0
- package/src/sdk/abTesting.test.ts +326 -0
- package/src/sdk/abTesting.ts +499 -0
- package/src/sdk/analytics.ts +77 -0
- package/src/sdk/cacheHeaders.test.ts +115 -0
- package/src/sdk/cacheHeaders.ts +424 -0
- package/src/sdk/cachedLoader.ts +364 -0
- package/src/sdk/clx.ts +5 -0
- package/src/sdk/cn.test.ts +34 -0
- package/src/sdk/cn.ts +28 -0
- package/src/sdk/composite.test.ts +121 -0
- package/src/sdk/composite.ts +114 -0
- package/src/sdk/cookie.test.ts +108 -0
- package/src/sdk/cookie.ts +129 -0
- package/src/sdk/crypto.ts +185 -0
- package/src/sdk/csp.ts +59 -0
- package/src/sdk/djb2.ts +20 -0
- package/src/sdk/encoding.test.ts +71 -0
- package/src/sdk/encoding.ts +47 -0
- package/src/sdk/env.ts +31 -0
- package/src/sdk/http.test.ts +71 -0
- package/src/sdk/http.ts +124 -0
- package/src/sdk/index.ts +90 -0
- package/src/sdk/inflightTimeout.test.ts +35 -0
- package/src/sdk/inflightTimeout.ts +53 -0
- package/src/sdk/instrumentedFetch.test.ts +559 -0
- package/src/sdk/instrumentedFetch.ts +339 -0
- package/src/sdk/invoke.test.ts +115 -0
- package/src/sdk/invoke.ts +260 -0
- package/src/sdk/logger.test.ts +432 -0
- package/src/sdk/logger.ts +304 -0
- package/src/sdk/mergeCacheControl.ts +150 -0
- package/src/sdk/normalizeUrls.ts +91 -0
- package/src/sdk/observability.ts +109 -0
- package/src/sdk/otel.test.ts +526 -0
- package/src/sdk/otel.ts +981 -0
- package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
- package/src/sdk/otelAdapters.test.ts +89 -0
- package/src/sdk/otelAdapters.ts +144 -0
- package/src/sdk/otelHttpLog.test.ts +457 -0
- package/src/sdk/otelHttpLog.ts +419 -0
- package/src/sdk/otelHttpMeter.test.ts +292 -0
- package/src/sdk/otelHttpMeter.ts +506 -0
- package/src/sdk/otelHttpTracer.test.ts +474 -0
- package/src/sdk/otelHttpTracer.ts +543 -0
- package/src/sdk/redirects.ts +225 -0
- package/src/sdk/requestContext.ts +281 -0
- package/src/sdk/requestContextStorage.browser.test.ts +29 -0
- package/src/sdk/requestContextStorage.browser.ts +43 -0
- package/src/sdk/requestContextStorage.ts +35 -0
- package/src/sdk/retry.ts +45 -0
- package/src/sdk/serverTimings.ts +68 -0
- package/src/sdk/signal.ts +42 -0
- package/src/sdk/sitemap.ts +160 -0
- package/src/sdk/urlRedaction.test.ts +73 -0
- package/src/sdk/urlRedaction.ts +82 -0
- package/src/sdk/urlUtils.ts +134 -0
- package/src/sdk/useDevice.test.ts +130 -0
- package/src/sdk/useDevice.ts +109 -0
- package/src/sdk/useDeviceContext.tsx +108 -0
- package/src/sdk/useId.ts +7 -0
- package/src/sdk/useScript.test.ts +128 -0
- package/src/sdk/useScript.ts +210 -0
- package/src/sdk/useSuggestions.test.ts +230 -0
- package/src/sdk/useSuggestions.ts +188 -0
- package/src/sdk/wrapCaughtErrors.ts +107 -0
- package/src/setup.ts +119 -0
- package/src/types/index.ts +39 -0
- package/src/types/widgets.ts +14 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OTLP/HTTP JSON metrics exporter — direct POST from a Cloudflare Worker
|
|
3
|
+
* to `deco-otel-ingest` `/v1/metrics`.
|
|
4
|
+
*
|
|
5
|
+
* Why direct POST and not Cloudflare Destinations? CF Destinations supports
|
|
6
|
+
* OTLP for `logs` and `traces` only; there is no `observability.metrics`
|
|
7
|
+
* block. Metrics travel from the site Worker to the ingestor over a normal
|
|
8
|
+
* outbound `fetch`. Sub-requests are free on the paid plan, and the
|
|
9
|
+
* ingestor's per-POST charge is captured in the cost model in
|
|
10
|
+
* `docs/observability.md`.
|
|
11
|
+
*
|
|
12
|
+
* **Aggregation model.** Buffers are per-isolate, accumulated forever (until
|
|
13
|
+
* the isolate dies), and exported with `AggregationTemporality = CUMULATIVE`
|
|
14
|
+
* (matching the `clickhouseexporter` schema used by the ingestor's
|
|
15
|
+
* `otel_metrics_{sum,gauge,histogram}` tables).
|
|
16
|
+
*
|
|
17
|
+
* **Flush triggers.**
|
|
18
|
+
* 1. `flush()` — caller-driven, used by `workerEntry` inside
|
|
19
|
+
* `ctx.waitUntil(...)` at request end. Throttled by `minFlushIntervalMs`
|
|
20
|
+
* per-isolate so a 1000-req/s isolate doesn't fire 1000 POSTs/s.
|
|
21
|
+
* 2. Buffer-size cap — when total pending datapoints exceeds
|
|
22
|
+
* `maxBufferDatapoints`, the next `flush()` ignores the cooldown.
|
|
23
|
+
* 3. Worker isolate shutdown — there is no "before-shutdown" hook on
|
|
24
|
+
* Workers; instead, every request's `ctx.waitUntil(flush())` keeps the
|
|
25
|
+
* buffer drained to roughly within one cooldown window of real time.
|
|
26
|
+
*
|
|
27
|
+
* **Data loss profile.** Documented in `docs/observability.md` under
|
|
28
|
+
* "Worker isolate lifecycle". Worst case is the cooldown window of
|
|
29
|
+
* datapoints lost on isolate teardown — for `minFlushIntervalMs: 5000`,
|
|
30
|
+
* that's ≤ 5s of metrics from one isolate. At fleet scale this is well
|
|
31
|
+
* under the 0.01% loss budget we agreed on.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import type { MeterAdapter } from "./observability";
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Types
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
type Labels = Record<string, string | number | boolean>;
|
|
41
|
+
|
|
42
|
+
export interface OtlpHttpMeterOptions {
|
|
43
|
+
/** Full OTLP/HTTP JSON metrics endpoint, e.g. `https://.../v1/metrics`. */
|
|
44
|
+
endpoint: string;
|
|
45
|
+
/** Resource attributes stamped on every OTLP payload (service.name etc.). */
|
|
46
|
+
resourceAttributes: Record<string, string>;
|
|
47
|
+
/** Scope name advertised in `scopeMetrics[].scope.name`. */
|
|
48
|
+
scopeName?: string;
|
|
49
|
+
/** Scope version. */
|
|
50
|
+
scopeVersion?: string;
|
|
51
|
+
/**
|
|
52
|
+
* Explicit histogram bounds. Default targets HTTP/sub-fetch latency in ms.
|
|
53
|
+
* Datapoints below the first bound land in bucket 0; above the last bound
|
|
54
|
+
* in the overflow bucket (length = bounds.length + 1).
|
|
55
|
+
*/
|
|
56
|
+
histogramBounds?: number[];
|
|
57
|
+
/** Hard cap on pending datapoints across all metric kinds. Default: 2000. */
|
|
58
|
+
maxBufferDatapoints?: number;
|
|
59
|
+
/** Cooldown between successful flushes (ms). Default: 5000. */
|
|
60
|
+
minFlushIntervalMs?: number;
|
|
61
|
+
/** Per-flush HTTP timeout (ms). Default: 5000. */
|
|
62
|
+
flushTimeoutMs?: number;
|
|
63
|
+
/**
|
|
64
|
+
* Extra HTTP headers merged into every OTLP POST (e.g. `Authorization: Bearer …`).
|
|
65
|
+
* `Content-Type: application/json` is always set and cannot be overridden here.
|
|
66
|
+
*/
|
|
67
|
+
headers?: Record<string, string>;
|
|
68
|
+
/**
|
|
69
|
+
* Test seam — override fetch for the flush path so unit tests can
|
|
70
|
+
* inspect the OTLP payload without going to the network.
|
|
71
|
+
*/
|
|
72
|
+
fetchImpl?: typeof fetch;
|
|
73
|
+
/**
|
|
74
|
+
* Test seam — override Date.now() for deterministic timestamps in
|
|
75
|
+
* snapshot tests.
|
|
76
|
+
*/
|
|
77
|
+
nowMs?: () => number;
|
|
78
|
+
/** Optional sink for transport errors so callers can surface them. */
|
|
79
|
+
onError?: (kind: "flush" | "overflow" | "kind-mismatch", err: unknown) => void;
|
|
80
|
+
/**
|
|
81
|
+
* Per-metric metadata emitted in the OTLP payload's `description` and
|
|
82
|
+
* `unit` fields. Looked up by metric name at flush time. Names absent
|
|
83
|
+
* from the map flush with empty description/unit (still valid OTLP).
|
|
84
|
+
* The framework owns the lookup; callers MUST NOT pass metadata at
|
|
85
|
+
* record time. See `MetricNames` / `METRIC_METADATA` in
|
|
86
|
+
* `middleware/observability.ts`.
|
|
87
|
+
*/
|
|
88
|
+
metricMetadata?: Record<string, { description?: string; unit?: string }>;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface OtlpHttpMeter extends MeterAdapter {
|
|
92
|
+
/** Always defined on this adapter — declared required to drop the `?.` at call sites. */
|
|
93
|
+
gaugeSet(name: string, value: number, labels?: Labels): void;
|
|
94
|
+
/** Always defined on this adapter — declared required to drop the `?.` at call sites. */
|
|
95
|
+
histogramRecord(name: string, value: number, labels?: Labels): void;
|
|
96
|
+
/** Force a flush, subject to the per-isolate cooldown. */
|
|
97
|
+
flush(): Promise<void>;
|
|
98
|
+
/** Pending datapoint count across all metric kinds. For tests + audit. */
|
|
99
|
+
pendingDatapointCount(): number;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
// Internal buffer shapes
|
|
104
|
+
// ---------------------------------------------------------------------------
|
|
105
|
+
|
|
106
|
+
type MetricKind = "counter" | "gauge" | "histogram";
|
|
107
|
+
|
|
108
|
+
interface CounterPoint {
|
|
109
|
+
value: number;
|
|
110
|
+
attrs: Labels;
|
|
111
|
+
startTimeUnixNano: string;
|
|
112
|
+
}
|
|
113
|
+
interface GaugePoint {
|
|
114
|
+
value: number;
|
|
115
|
+
attrs: Labels;
|
|
116
|
+
timeUnixNano: string;
|
|
117
|
+
}
|
|
118
|
+
interface HistogramPoint {
|
|
119
|
+
count: number;
|
|
120
|
+
sum: number;
|
|
121
|
+
min: number;
|
|
122
|
+
max: number;
|
|
123
|
+
bucketCounts: number[];
|
|
124
|
+
attrs: Labels;
|
|
125
|
+
startTimeUnixNano: string;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
interface MetricEntry {
|
|
129
|
+
kind: MetricKind;
|
|
130
|
+
counter?: Map<string, CounterPoint>;
|
|
131
|
+
gauge?: Map<string, GaugePoint>;
|
|
132
|
+
histogram?: Map<string, HistogramPoint>;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ---------------------------------------------------------------------------
|
|
136
|
+
// Factory
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
|
|
139
|
+
const DEFAULT_HISTOGRAM_BOUNDS = [
|
|
140
|
+
// ms — tuned for HTTP/sub-fetch latency. Coarser bounds than VTEX-specific
|
|
141
|
+
// commerce histograms; sites that need finer buckets can register their
|
|
142
|
+
// own histogram bounds in a follow-up.
|
|
143
|
+
5, 10, 25, 50, 75, 100, 250, 500, 1000,
|
|
144
|
+
];
|
|
145
|
+
|
|
146
|
+
export function createOtlpHttpMeterAdapter(options: OtlpHttpMeterOptions): OtlpHttpMeter {
|
|
147
|
+
const endpoint = options.endpoint;
|
|
148
|
+
const resourceAttributes = options.resourceAttributes;
|
|
149
|
+
const scopeName = options.scopeName ?? "@decocms/start";
|
|
150
|
+
const scopeVersion = options.scopeVersion ?? "";
|
|
151
|
+
const histogramBounds = options.histogramBounds ?? DEFAULT_HISTOGRAM_BOUNDS;
|
|
152
|
+
const maxBuffer = options.maxBufferDatapoints ?? 2000;
|
|
153
|
+
const minFlushIntervalMs = options.minFlushIntervalMs ?? 5000;
|
|
154
|
+
const flushTimeoutMs = options.flushTimeoutMs ?? 5000;
|
|
155
|
+
const extraHeaders = options.headers ?? {};
|
|
156
|
+
const fetchImpl = options.fetchImpl ?? fetch;
|
|
157
|
+
const now = options.nowMs ?? (() => Date.now());
|
|
158
|
+
const onError = options.onError;
|
|
159
|
+
const metricMetadata = options.metricMetadata ?? {};
|
|
160
|
+
|
|
161
|
+
// Buffer state — per-isolate, never reset (CUMULATIVE temporality).
|
|
162
|
+
const metrics = new Map<string, MetricEntry>();
|
|
163
|
+
// Isolate boot wall-clock — counters/histograms anchor their startTime here.
|
|
164
|
+
const isolateStartMs = now();
|
|
165
|
+
// Per-isolate flush throttle.
|
|
166
|
+
let lastFlushAt = 0;
|
|
167
|
+
let inflight: Promise<void> | null = null;
|
|
168
|
+
|
|
169
|
+
function attrKey(attrs?: Labels): string {
|
|
170
|
+
if (!attrs) return "";
|
|
171
|
+
const keys = Object.keys(attrs).sort();
|
|
172
|
+
const parts: string[] = [];
|
|
173
|
+
for (const k of keys) {
|
|
174
|
+
const v = attrs[k];
|
|
175
|
+
if (v === undefined || v === null) continue;
|
|
176
|
+
parts.push(`${k}=${String(v)}`);
|
|
177
|
+
}
|
|
178
|
+
return parts.join("\u0001");
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Look up an existing entry without creating one. The two helpers below
|
|
183
|
+
* (`checkAdmissibility`, `materializeEntry`) split what was a single
|
|
184
|
+
* `getOrCreate` so the overflow check can run BEFORE we materialize a
|
|
185
|
+
* new entry — otherwise a dropped datapoint leaves a permanent empty
|
|
186
|
+
* `MetricEntry` in the `metrics` map that gets serialized as an empty
|
|
187
|
+
* OTLP envelope each flush, inflating payloads and leaking memory.
|
|
188
|
+
*/
|
|
189
|
+
function checkAdmissibility(
|
|
190
|
+
name: string,
|
|
191
|
+
kind: MetricKind,
|
|
192
|
+
): { entry: MetricEntry | null; isNewName: boolean } {
|
|
193
|
+
const existing = metrics.get(name);
|
|
194
|
+
if (!existing) return { entry: null, isNewName: true };
|
|
195
|
+
if (existing.kind !== kind) {
|
|
196
|
+
onError?.(
|
|
197
|
+
"kind-mismatch",
|
|
198
|
+
new Error(`metric "${name}" already registered as ${existing.kind}`),
|
|
199
|
+
);
|
|
200
|
+
return { entry: null, isNewName: false };
|
|
201
|
+
}
|
|
202
|
+
return { entry: existing, isNewName: false };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function materializeEntry(name: string, kind: MetricKind): MetricEntry {
|
|
206
|
+
const entry: MetricEntry = { kind };
|
|
207
|
+
if (kind === "counter") entry.counter = new Map();
|
|
208
|
+
else if (kind === "gauge") entry.gauge = new Map();
|
|
209
|
+
else entry.histogram = new Map();
|
|
210
|
+
metrics.set(name, entry);
|
|
211
|
+
return entry;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function pendingDatapointCount(): number {
|
|
215
|
+
let n = 0;
|
|
216
|
+
for (const entry of metrics.values()) {
|
|
217
|
+
if (entry.counter) n += entry.counter.size;
|
|
218
|
+
if (entry.gauge) n += entry.gauge.size;
|
|
219
|
+
if (entry.histogram) n += entry.histogram.size;
|
|
220
|
+
}
|
|
221
|
+
return n;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function counterInc(name: string, value = 1, labels?: Labels) {
|
|
225
|
+
const { entry: existing, isNewName } = checkAdmissibility(name, "counter");
|
|
226
|
+
if (existing === null && !isNewName) return; // kind mismatch
|
|
227
|
+
const key = attrKey(labels);
|
|
228
|
+
const isNewDatapoint = !existing?.counter?.has(key);
|
|
229
|
+
if (isNewDatapoint && pendingDatapointCount() >= maxBuffer) {
|
|
230
|
+
onError?.("overflow", new Error(`metric buffer at cap (${maxBuffer}) — dropping "${name}"`));
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
const entry = existing ?? materializeEntry(name, "counter");
|
|
234
|
+
if (!entry.counter) return;
|
|
235
|
+
let point = entry.counter.get(key);
|
|
236
|
+
if (!point) {
|
|
237
|
+
point = {
|
|
238
|
+
value: 0,
|
|
239
|
+
attrs: labels ? { ...labels } : {},
|
|
240
|
+
startTimeUnixNano: msToNs(isolateStartMs),
|
|
241
|
+
};
|
|
242
|
+
entry.counter.set(key, point);
|
|
243
|
+
}
|
|
244
|
+
point.value += value;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function gaugeSet(name: string, value: number, labels?: Labels) {
|
|
248
|
+
const { entry: existing, isNewName } = checkAdmissibility(name, "gauge");
|
|
249
|
+
if (existing === null && !isNewName) return; // kind mismatch
|
|
250
|
+
const key = attrKey(labels);
|
|
251
|
+
const isNewDatapoint = !existing?.gauge?.has(key);
|
|
252
|
+
if (isNewDatapoint && pendingDatapointCount() >= maxBuffer) {
|
|
253
|
+
onError?.("overflow", new Error(`metric buffer at cap (${maxBuffer}) — dropping "${name}"`));
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
const entry = existing ?? materializeEntry(name, "gauge");
|
|
257
|
+
if (!entry.gauge) return;
|
|
258
|
+
entry.gauge.set(key, {
|
|
259
|
+
value,
|
|
260
|
+
attrs: labels ? { ...labels } : {},
|
|
261
|
+
timeUnixNano: msToNs(now()),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function histogramRecord(name: string, value: number, labels?: Labels) {
|
|
266
|
+
const { entry: existing, isNewName } = checkAdmissibility(name, "histogram");
|
|
267
|
+
if (existing === null && !isNewName) return; // kind mismatch
|
|
268
|
+
const key = attrKey(labels);
|
|
269
|
+
const isNewDatapoint = !existing?.histogram?.has(key);
|
|
270
|
+
if (isNewDatapoint && pendingDatapointCount() >= maxBuffer) {
|
|
271
|
+
onError?.("overflow", new Error(`metric buffer at cap (${maxBuffer}) — dropping "${name}"`));
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
const entry = existing ?? materializeEntry(name, "histogram");
|
|
275
|
+
if (!entry.histogram) return;
|
|
276
|
+
let point = entry.histogram.get(key);
|
|
277
|
+
if (!point) {
|
|
278
|
+
point = {
|
|
279
|
+
count: 0,
|
|
280
|
+
sum: 0,
|
|
281
|
+
min: Number.POSITIVE_INFINITY,
|
|
282
|
+
max: Number.NEGATIVE_INFINITY,
|
|
283
|
+
bucketCounts: new Array(histogramBounds.length + 1).fill(0),
|
|
284
|
+
attrs: labels ? { ...labels } : {},
|
|
285
|
+
startTimeUnixNano: msToNs(isolateStartMs),
|
|
286
|
+
};
|
|
287
|
+
entry.histogram.set(key, point);
|
|
288
|
+
}
|
|
289
|
+
point.count += 1;
|
|
290
|
+
point.sum += value;
|
|
291
|
+
if (value < point.min) point.min = value;
|
|
292
|
+
if (value > point.max) point.max = value;
|
|
293
|
+
// Locate bucket. histogramBounds is small (<=20); linear scan is fine.
|
|
294
|
+
let bucketIdx = histogramBounds.length;
|
|
295
|
+
for (let i = 0; i < histogramBounds.length; i++) {
|
|
296
|
+
if (value <= histogramBounds[i]) {
|
|
297
|
+
bucketIdx = i;
|
|
298
|
+
break;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
point.bucketCounts[bucketIdx] += 1;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
async function doFlush(): Promise<void> {
|
|
305
|
+
if (metrics.size === 0) return;
|
|
306
|
+
|
|
307
|
+
const flushAtNs = msToNs(now());
|
|
308
|
+
const payload = serializeOtlp(metrics, {
|
|
309
|
+
resourceAttributes,
|
|
310
|
+
scopeName,
|
|
311
|
+
scopeVersion,
|
|
312
|
+
histogramBounds,
|
|
313
|
+
flushAtNs,
|
|
314
|
+
metricMetadata,
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
const controller = new AbortController();
|
|
318
|
+
const timer = setTimeout(() => controller.abort(), flushTimeoutMs);
|
|
319
|
+
try {
|
|
320
|
+
const res = await fetchImpl(endpoint, {
|
|
321
|
+
method: "POST",
|
|
322
|
+
headers: { ...extraHeaders, "Content-Type": "application/json" },
|
|
323
|
+
body: JSON.stringify(payload),
|
|
324
|
+
signal: controller.signal,
|
|
325
|
+
});
|
|
326
|
+
if (!res.ok) {
|
|
327
|
+
// Drain the body so the underlying connection can be reused, then
|
|
328
|
+
// surface as a flush error. Don't throw — flush failures must never
|
|
329
|
+
// surface on the request hot path.
|
|
330
|
+
try {
|
|
331
|
+
await res.text();
|
|
332
|
+
} catch {
|
|
333
|
+
/* swallow */
|
|
334
|
+
}
|
|
335
|
+
onError?.("flush", new Error(`POST ${endpoint} → ${res.status}`));
|
|
336
|
+
}
|
|
337
|
+
} catch (err) {
|
|
338
|
+
onError?.("flush", err);
|
|
339
|
+
} finally {
|
|
340
|
+
clearTimeout(timer);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
async function flush(): Promise<void> {
|
|
345
|
+
// If a flush is in flight, reuse it — concurrent requests should not
|
|
346
|
+
// pile up POSTs. The in-flight POST already snapshotted the buffer at
|
|
347
|
+
// its enqueue time; new datapoints land in the buffer and will go out
|
|
348
|
+
// on the next flush.
|
|
349
|
+
if (inflight) return inflight;
|
|
350
|
+
|
|
351
|
+
const elapsed = now() - lastFlushAt;
|
|
352
|
+
const overCap = pendingDatapointCount() >= maxBuffer;
|
|
353
|
+
if (!overCap && elapsed < minFlushIntervalMs) {
|
|
354
|
+
// Cooldown not elapsed and buffer is not at the cap — skip.
|
|
355
|
+
return;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
inflight = doFlush().finally(() => {
|
|
359
|
+
lastFlushAt = now();
|
|
360
|
+
inflight = null;
|
|
361
|
+
});
|
|
362
|
+
return inflight;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
return {
|
|
366
|
+
counterInc,
|
|
367
|
+
gaugeSet,
|
|
368
|
+
histogramRecord,
|
|
369
|
+
flush,
|
|
370
|
+
pendingDatapointCount,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// OTLP/HTTP JSON serialization
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
|
|
378
|
+
function msToNs(ms: number): string {
|
|
379
|
+
// OTLP wants nanoseconds-since-epoch as a string (uint64). Workers don't
|
|
380
|
+
// give us better than ms precision in `Date.now()`; we pad with zeros.
|
|
381
|
+
return `${Math.floor(ms)}000000`;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
function attrsToOtlp(attrs: Labels): Array<{
|
|
385
|
+
key: string;
|
|
386
|
+
value:
|
|
387
|
+
| { stringValue: string }
|
|
388
|
+
| { intValue: string }
|
|
389
|
+
| { doubleValue: number }
|
|
390
|
+
| { boolValue: boolean };
|
|
391
|
+
}> {
|
|
392
|
+
const out: ReturnType<typeof attrsToOtlp> = [];
|
|
393
|
+
for (const k of Object.keys(attrs).sort()) {
|
|
394
|
+
const v = attrs[k];
|
|
395
|
+
if (v === undefined || v === null) continue;
|
|
396
|
+
if (typeof v === "string") out.push({ key: k, value: { stringValue: v } });
|
|
397
|
+
else if (typeof v === "boolean") out.push({ key: k, value: { boolValue: v } });
|
|
398
|
+
else if (Number.isInteger(v)) out.push({ key: k, value: { intValue: String(v) } });
|
|
399
|
+
else out.push({ key: k, value: { doubleValue: v } });
|
|
400
|
+
}
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
interface SerializeOpts {
|
|
405
|
+
resourceAttributes: Record<string, string>;
|
|
406
|
+
scopeName: string;
|
|
407
|
+
scopeVersion: string;
|
|
408
|
+
histogramBounds: number[];
|
|
409
|
+
flushAtNs: string;
|
|
410
|
+
metricMetadata: Record<string, { description?: string; unit?: string }>;
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
function serializeOtlp(
|
|
414
|
+
metrics: Map<string, MetricEntry>,
|
|
415
|
+
opts: SerializeOpts,
|
|
416
|
+
): { resourceMetrics: unknown[] } {
|
|
417
|
+
const otlpMetrics: unknown[] = [];
|
|
418
|
+
|
|
419
|
+
for (const [name, entry] of metrics) {
|
|
420
|
+
const meta = opts.metricMetadata[name];
|
|
421
|
+
const description = meta?.description ?? "";
|
|
422
|
+
const unit = meta?.unit ?? "";
|
|
423
|
+
if (entry.kind === "counter" && entry.counter) {
|
|
424
|
+
const dataPoints: unknown[] = [];
|
|
425
|
+
for (const point of entry.counter.values()) {
|
|
426
|
+
dataPoints.push({
|
|
427
|
+
attributes: attrsToOtlp(point.attrs),
|
|
428
|
+
startTimeUnixNano: point.startTimeUnixNano,
|
|
429
|
+
timeUnixNano: opts.flushAtNs,
|
|
430
|
+
asDouble: point.value,
|
|
431
|
+
});
|
|
432
|
+
}
|
|
433
|
+
otlpMetrics.push({
|
|
434
|
+
name,
|
|
435
|
+
description,
|
|
436
|
+
unit,
|
|
437
|
+
sum: {
|
|
438
|
+
aggregationTemporality: 2, // CUMULATIVE
|
|
439
|
+
isMonotonic: true,
|
|
440
|
+
dataPoints,
|
|
441
|
+
},
|
|
442
|
+
});
|
|
443
|
+
} else if (entry.kind === "gauge" && entry.gauge) {
|
|
444
|
+
const dataPoints: unknown[] = [];
|
|
445
|
+
for (const point of entry.gauge.values()) {
|
|
446
|
+
dataPoints.push({
|
|
447
|
+
attributes: attrsToOtlp(point.attrs),
|
|
448
|
+
timeUnixNano: point.timeUnixNano,
|
|
449
|
+
asDouble: point.value,
|
|
450
|
+
});
|
|
451
|
+
}
|
|
452
|
+
otlpMetrics.push({
|
|
453
|
+
name,
|
|
454
|
+
description,
|
|
455
|
+
unit,
|
|
456
|
+
gauge: { dataPoints },
|
|
457
|
+
});
|
|
458
|
+
} else if (entry.kind === "histogram" && entry.histogram) {
|
|
459
|
+
const dataPoints: unknown[] = [];
|
|
460
|
+
for (const point of entry.histogram.values()) {
|
|
461
|
+
dataPoints.push({
|
|
462
|
+
attributes: attrsToOtlp(point.attrs),
|
|
463
|
+
startTimeUnixNano: point.startTimeUnixNano,
|
|
464
|
+
timeUnixNano: opts.flushAtNs,
|
|
465
|
+
count: String(point.count),
|
|
466
|
+
sum: point.sum,
|
|
467
|
+
min: point.min === Number.POSITIVE_INFINITY ? 0 : point.min,
|
|
468
|
+
max: point.max === Number.NEGATIVE_INFINITY ? 0 : point.max,
|
|
469
|
+
bucketCounts: point.bucketCounts.map((c) => String(c)),
|
|
470
|
+
explicitBounds: opts.histogramBounds,
|
|
471
|
+
});
|
|
472
|
+
}
|
|
473
|
+
otlpMetrics.push({
|
|
474
|
+
name,
|
|
475
|
+
description,
|
|
476
|
+
unit,
|
|
477
|
+
histogram: {
|
|
478
|
+
aggregationTemporality: 2, // CUMULATIVE
|
|
479
|
+
dataPoints,
|
|
480
|
+
},
|
|
481
|
+
});
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
const resourceAttrs: Array<{
|
|
486
|
+
key: string;
|
|
487
|
+
value: { stringValue: string };
|
|
488
|
+
}> = [];
|
|
489
|
+
for (const k of Object.keys(opts.resourceAttributes).sort()) {
|
|
490
|
+
resourceAttrs.push({ key: k, value: { stringValue: opts.resourceAttributes[k] } });
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
resourceMetrics: [
|
|
495
|
+
{
|
|
496
|
+
resource: { attributes: resourceAttrs },
|
|
497
|
+
scopeMetrics: [
|
|
498
|
+
{
|
|
499
|
+
scope: { name: opts.scopeName, version: opts.scopeVersion },
|
|
500
|
+
metrics: otlpMetrics,
|
|
501
|
+
},
|
|
502
|
+
],
|
|
503
|
+
},
|
|
504
|
+
],
|
|
505
|
+
};
|
|
506
|
+
}
|