@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.
Files changed (111) hide show
  1. package/package.json +97 -0
  2. package/src/cms/applySectionConventions.ts +128 -0
  3. package/src/cms/blockSource.test.ts +67 -0
  4. package/src/cms/blockSource.ts +124 -0
  5. package/src/cms/index.ts +104 -0
  6. package/src/cms/layoutCacheRace.test.ts +146 -0
  7. package/src/cms/loadDecofileDirectory.test.ts +52 -0
  8. package/src/cms/loadDecofileDirectory.ts +48 -0
  9. package/src/cms/loader.test.ts +184 -0
  10. package/src/cms/loader.ts +284 -0
  11. package/src/cms/registry.test.ts +118 -0
  12. package/src/cms/registry.ts +252 -0
  13. package/src/cms/resolve.test.ts +547 -0
  14. package/src/cms/resolve.ts +2003 -0
  15. package/src/cms/schema.ts +993 -0
  16. package/src/cms/sectionLoaders.test.ts +409 -0
  17. package/src/cms/sectionLoaders.ts +562 -0
  18. package/src/cms/sectionMixins.test.ts +163 -0
  19. package/src/cms/sectionMixins.ts +153 -0
  20. package/src/hooks/LazySection.tsx +121 -0
  21. package/src/hooks/LiveControls.tsx +122 -0
  22. package/src/hooks/RenderSection.test.tsx +81 -0
  23. package/src/hooks/RenderSection.tsx +91 -0
  24. package/src/hooks/SectionErrorFallback.tsx +97 -0
  25. package/src/hooks/index.ts +4 -0
  26. package/src/index.ts +8 -0
  27. package/src/matchers/builtins.test.ts +251 -0
  28. package/src/matchers/builtins.ts +437 -0
  29. package/src/matchers/countryNames.ts +104 -0
  30. package/src/matchers/override.test.ts +205 -0
  31. package/src/matchers/override.ts +136 -0
  32. package/src/matchers/posthog.ts +154 -0
  33. package/src/middleware/decoState.ts +55 -0
  34. package/src/middleware/healthMetrics.ts +133 -0
  35. package/src/middleware/hydrationContext.test.ts +61 -0
  36. package/src/middleware/hydrationContext.ts +79 -0
  37. package/src/middleware/index.ts +88 -0
  38. package/src/middleware/liveness.ts +21 -0
  39. package/src/middleware/observability.test.ts +238 -0
  40. package/src/middleware/observability.ts +620 -0
  41. package/src/middleware/validateSection.test.ts +147 -0
  42. package/src/middleware/validateSection.ts +100 -0
  43. package/src/sdk/abTesting.test.ts +326 -0
  44. package/src/sdk/abTesting.ts +499 -0
  45. package/src/sdk/analytics.ts +77 -0
  46. package/src/sdk/cacheHeaders.test.ts +115 -0
  47. package/src/sdk/cacheHeaders.ts +424 -0
  48. package/src/sdk/cachedLoader.ts +364 -0
  49. package/src/sdk/clx.ts +5 -0
  50. package/src/sdk/cn.test.ts +34 -0
  51. package/src/sdk/cn.ts +28 -0
  52. package/src/sdk/composite.test.ts +121 -0
  53. package/src/sdk/composite.ts +114 -0
  54. package/src/sdk/cookie.test.ts +108 -0
  55. package/src/sdk/cookie.ts +129 -0
  56. package/src/sdk/crypto.ts +185 -0
  57. package/src/sdk/csp.ts +59 -0
  58. package/src/sdk/djb2.ts +20 -0
  59. package/src/sdk/encoding.test.ts +71 -0
  60. package/src/sdk/encoding.ts +47 -0
  61. package/src/sdk/env.ts +31 -0
  62. package/src/sdk/http.test.ts +71 -0
  63. package/src/sdk/http.ts +124 -0
  64. package/src/sdk/index.ts +90 -0
  65. package/src/sdk/inflightTimeout.test.ts +35 -0
  66. package/src/sdk/inflightTimeout.ts +53 -0
  67. package/src/sdk/instrumentedFetch.test.ts +559 -0
  68. package/src/sdk/instrumentedFetch.ts +339 -0
  69. package/src/sdk/invoke.test.ts +115 -0
  70. package/src/sdk/invoke.ts +260 -0
  71. package/src/sdk/logger.test.ts +432 -0
  72. package/src/sdk/logger.ts +304 -0
  73. package/src/sdk/mergeCacheControl.ts +150 -0
  74. package/src/sdk/normalizeUrls.ts +91 -0
  75. package/src/sdk/observability.ts +109 -0
  76. package/src/sdk/otel.test.ts +526 -0
  77. package/src/sdk/otel.ts +981 -0
  78. package/src/sdk/otelAdapters/clickhouseCollector.ts +65 -0
  79. package/src/sdk/otelAdapters.test.ts +89 -0
  80. package/src/sdk/otelAdapters.ts +144 -0
  81. package/src/sdk/otelHttpLog.test.ts +457 -0
  82. package/src/sdk/otelHttpLog.ts +419 -0
  83. package/src/sdk/otelHttpMeter.test.ts +292 -0
  84. package/src/sdk/otelHttpMeter.ts +506 -0
  85. package/src/sdk/otelHttpTracer.test.ts +474 -0
  86. package/src/sdk/otelHttpTracer.ts +543 -0
  87. package/src/sdk/redirects.ts +225 -0
  88. package/src/sdk/requestContext.ts +281 -0
  89. package/src/sdk/requestContextStorage.browser.test.ts +29 -0
  90. package/src/sdk/requestContextStorage.browser.ts +43 -0
  91. package/src/sdk/requestContextStorage.ts +35 -0
  92. package/src/sdk/retry.ts +45 -0
  93. package/src/sdk/serverTimings.ts +68 -0
  94. package/src/sdk/signal.ts +42 -0
  95. package/src/sdk/sitemap.ts +160 -0
  96. package/src/sdk/urlRedaction.test.ts +73 -0
  97. package/src/sdk/urlRedaction.ts +82 -0
  98. package/src/sdk/urlUtils.ts +134 -0
  99. package/src/sdk/useDevice.test.ts +130 -0
  100. package/src/sdk/useDevice.ts +109 -0
  101. package/src/sdk/useDeviceContext.tsx +108 -0
  102. package/src/sdk/useId.ts +7 -0
  103. package/src/sdk/useScript.test.ts +128 -0
  104. package/src/sdk/useScript.ts +210 -0
  105. package/src/sdk/useSuggestions.test.ts +230 -0
  106. package/src/sdk/useSuggestions.ts +188 -0
  107. package/src/sdk/wrapCaughtErrors.ts +107 -0
  108. package/src/setup.ts +119 -0
  109. package/src/types/index.ts +39 -0
  110. package/src/types/widgets.ts +14 -0
  111. 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
+ }