@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,419 @@
1
+ /**
2
+ * Direct OTLP/HTTP JSON error-log exporter.
3
+ *
4
+ * Errors are too important to leave to head sampling. When CF Destinations'
5
+ * `logs.head_sampling_rate` drops below 1.0 (the cost model in
6
+ * `docs/observability.md` lowers it to `0.01` once this channel lands),
7
+ * a 1%-sampled log pipe would lose 99 of every 100 errors emitted by
8
+ * `logger.error(...)`. Sites lose the one signal they care about most.
9
+ *
10
+ * This adapter solves it by carrying `level: "error"` log records over a
11
+ * separate, framework-controlled pipe direct to `deco-otel-ingest`
12
+ * `/v1/logs`, the same endpoint CF Destinations targets. The two pipes
13
+ * write to the same `otel_logs` table — the ingestor doesn't know or
14
+ * care which transport the record arrived on, and dashboards / SQL
15
+ * queries are unchanged.
16
+ *
17
+ * Design:
18
+ *
19
+ * - Filters out `debug`/`info`/`warn` upstream of the buffer — only
20
+ * errors travel through this transport. Calls below "error" are a
21
+ * no-op on this adapter; the composite logger fans them out to the
22
+ * default console-JSON adapter which CF Destinations then samples
23
+ * according to `logs.head_sampling_rate`.
24
+ * - Per-isolate token-bucket rate limiter prevents log storms (a
25
+ * pathological error loop blasting 10K errors/sec into the ingestor
26
+ * would be a self-inflicted denial-of-service against stats-lake).
27
+ * Default: 100 errors per minute, burst capacity of 20.
28
+ * - Buffer + flush + cooldown identical to `otelHttpMeter.ts`. The
29
+ * same `ctx.waitUntil(flush())` in `instrumentWorker` drains both.
30
+ * - Wire format: OTLP/HTTP JSON `ResourceLogs` payload matching the
31
+ * shape CF Destinations produces, so the existing ingestor handler
32
+ * accepts both transports unchanged.
33
+ *
34
+ * Why a separate adapter and not a sampling-bypass flag on
35
+ * `defaultLoggerAdapter`: the default writes via `console.error` which
36
+ * is what CF Destinations samples. Bypassing CF Destinations means
37
+ * NOT writing via `console.*` — a different transport, different
38
+ * code path, different failure modes. Better as a dedicated adapter
39
+ * composed onto the active logger via `createCompositeLogger`.
40
+ */
41
+
42
+ import { getActiveSpan } from "./observability";
43
+ import type { LoggerAdapter, LogLevel } from "./logger";
44
+
45
+ // ---------------------------------------------------------------------------
46
+ // Types
47
+ // ---------------------------------------------------------------------------
48
+
49
+ export interface OtlpHttpLogOptions {
50
+ /** Full OTLP/HTTP JSON logs endpoint, e.g. `https://.../v1/logs`. */
51
+ endpoint: string;
52
+ /** Resource attributes stamped on every payload (service.name etc.). */
53
+ resourceAttributes: Record<string, string>;
54
+ /** Scope name advertised in `scopeLogs[].scope.name`. */
55
+ scopeName?: string;
56
+ /** Scope version. */
57
+ scopeVersion?: string;
58
+ /** Hard cap on pending log records. Default: 500. */
59
+ maxBufferRecords?: number;
60
+ /** Cooldown between successful flushes (ms). Default: 5000. */
61
+ minFlushIntervalMs?: number;
62
+ /** Per-flush HTTP timeout (ms). Default: 5000. */
63
+ flushTimeoutMs?: number;
64
+ /**
65
+ * Token-bucket parameters. The bucket holds up to `burstCapacity`
66
+ * tokens and refills at `refillPerMinute` per minute. Each log record
67
+ * costs one token. When the bucket is empty, records are dropped (with
68
+ * `onError("rate-limit", ...)`) until refill resumes.
69
+ * Defaults: 500 burst, 1000/min — sized for console monkey-patch, which
70
+ * routes ALL console.* (including third-party libs at boot) through this
71
+ * adapter. The old 20/100 defaults were sized for error-only traffic.
72
+ */
73
+ rateLimitBurstCapacity?: number;
74
+ rateLimitRefillPerMinute?: number;
75
+ /**
76
+ * Minimum log level that this adapter forwards to OTLP. Levels below
77
+ * this threshold are silently dropped at ingress. Defaults to `"error"`
78
+ * — production-safe (only errors travel direct-POST; info/warn flow
79
+ * through CF Destinations sampling). Set to `"info"` or `"debug"` in
80
+ * local dev to capture lower-severity logs in the same channel.
81
+ * Resolved from `DECO_OTEL_LOGS_MIN_LEVEL` in `bootObservability`.
82
+ */
83
+ minLevel?: LogLevel;
84
+ /**
85
+ * Extra HTTP headers merged into every OTLP POST (e.g. `Authorization: Bearer …`).
86
+ * `Content-Type: application/json` is always set and cannot be overridden here.
87
+ */
88
+ headers?: Record<string, string>;
89
+ /** Test seam — override fetch. */
90
+ fetchImpl?: typeof fetch;
91
+ /** Test seam — override Date.now(). */
92
+ nowMs?: () => number;
93
+ /**
94
+ * Test seam — override getActiveSpan. Production code uses the
95
+ * framework's AsyncLocalStorage-backed getActiveSpan automatically.
96
+ */
97
+ getActiveSpanFn?: () => { spanContext?: () => { traceId?: string; spanId?: string; traceFlags?: number } } | undefined | null;
98
+ /** Optional sink for transport / rate-limit / overflow errors. */
99
+ onError?: (kind: "flush" | "overflow" | "rate-limit", err: unknown) => void;
100
+ /**
101
+ * Called when an `error`-level log fires with an active trace context.
102
+ * Used by the caller to promote unsampled traces so their spans are
103
+ * exported regardless of head-sampling decisions.
104
+ */
105
+ promoteTrace?: (traceId: string) => void;
106
+ }
107
+
108
+ export interface OtlpHttpLog {
109
+ adapter: LoggerAdapter;
110
+ /** Force a flush, subject to the per-isolate cooldown. */
111
+ flush(): Promise<void>;
112
+ /** Pending log record count. For tests + audit. */
113
+ pendingRecordCount(): number;
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Internal types
118
+ // ---------------------------------------------------------------------------
119
+
120
+ interface PendingRecord {
121
+ timeUnixNano: string;
122
+ observedTimeUnixNano: string;
123
+ severityNumber: number;
124
+ severityText: string;
125
+ body: string;
126
+ attributes: Record<string, unknown>;
127
+ traceId: string;
128
+ spanId: string;
129
+ traceFlags: number;
130
+ }
131
+
132
+ // OTel SeverityNumber values, OTel spec. See
133
+ // https://opentelemetry.io/docs/specs/otel/logs/data-model/#field-severitynumber
134
+ const SEVERITY_NUMBER: Record<LogLevel, number> = {
135
+ debug: 5,
136
+ info: 9,
137
+ warn: 13,
138
+ error: 17,
139
+ };
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // Factory
143
+ // ---------------------------------------------------------------------------
144
+
145
+ export function createOtlpHttpLogAdapter(options: OtlpHttpLogOptions): OtlpHttpLog {
146
+ const endpoint = options.endpoint;
147
+ const resourceAttributes = options.resourceAttributes;
148
+ const scopeName = options.scopeName ?? "@decocms/start";
149
+ const scopeVersion = options.scopeVersion ?? "";
150
+ const maxBuffer = options.maxBufferRecords ?? 500;
151
+ const minFlushIntervalMs = options.minFlushIntervalMs ?? 5000;
152
+ const flushTimeoutMs = options.flushTimeoutMs ?? 5000;
153
+ const burstCapacity = options.rateLimitBurstCapacity ?? 500;
154
+ const refillPerMinute = options.rateLimitRefillPerMinute ?? 1000;
155
+ const minSeverity = SEVERITY_NUMBER[options.minLevel ?? "error"];
156
+ const extraHeaders = options.headers ?? {};
157
+ const fetchImpl = options.fetchImpl ?? fetch;
158
+ const now = options.nowMs ?? (() => Date.now());
159
+ const onError = options.onError;
160
+ const promoteTrace = options.promoteTrace;
161
+ const getSpan = options.getActiveSpanFn ?? getActiveSpan;
162
+
163
+ const buffer: PendingRecord[] = [];
164
+ // Token bucket — starts full.
165
+ let tokens = burstCapacity;
166
+ let tokensLastRefilledAt = now();
167
+
168
+ let lastFlushAt = 0;
169
+ let inflight: Promise<void> | null = null;
170
+
171
+ function refillTokens(): void {
172
+ const t = now();
173
+ const elapsedMs = t - tokensLastRefilledAt;
174
+ if (elapsedMs <= 0) return;
175
+ // refillPerMinute tokens per 60_000ms.
176
+ const refill = (elapsedMs / 60_000) * refillPerMinute;
177
+ tokens = Math.min(burstCapacity, tokens + refill);
178
+ tokensLastRefilledAt = t;
179
+ }
180
+
181
+ function tryConsumeToken(): boolean {
182
+ refillTokens();
183
+ if (tokens < 1) return false;
184
+ tokens -= 1;
185
+ return true;
186
+ }
187
+
188
+ const adapter: LoggerAdapter = {
189
+ log(level, msg, attrs) {
190
+ // Filter by severity threshold. Levels below minLevel are dropped.
191
+ if (SEVERITY_NUMBER[level] < minSeverity) return;
192
+
193
+ // No SDK-level sampling for info/debug. Volume is controlled by
194
+ // minLevel (severity threshold) here, and by the probabilistic
195
+ // sampler in the otel-ingest collector — keeping log and trace
196
+ // sampling pipelines fully independent per OTel spec.
197
+
198
+ if (!tryConsumeToken()) {
199
+ onError?.(
200
+ "rate-limit",
201
+ new Error(`rate limit exceeded: ${refillPerMinute}/min, burst ${burstCapacity}`),
202
+ );
203
+ return;
204
+ }
205
+
206
+ if (buffer.length >= maxBuffer) {
207
+ onError?.(
208
+ "overflow",
209
+ new Error(`error-log buffer at cap (${maxBuffer}) — dropping record`),
210
+ );
211
+ return;
212
+ }
213
+
214
+ const spanCtx = getSpan()?.spanContext?.();
215
+ const t = msToNs(now());
216
+ buffer.push({
217
+ timeUnixNano: t,
218
+ observedTimeUnixNano: t,
219
+ severityNumber: SEVERITY_NUMBER[level],
220
+ severityText: level,
221
+ body: msg,
222
+ attributes: attrs ? { ...attrs } : {},
223
+ traceId: spanCtx?.traceId ?? "",
224
+ spanId: spanCtx?.spanId ?? "",
225
+ traceFlags: spanCtx?.traceFlags ?? 0,
226
+ });
227
+
228
+ if (level === "error" && spanCtx?.traceId) {
229
+ promoteTrace?.(spanCtx.traceId);
230
+ }
231
+ },
232
+ };
233
+
234
+ async function doFlush(): Promise<void> {
235
+ if (buffer.length === 0) return;
236
+ // Snapshot + remove BEFORE the network call so concurrent records
237
+ // landing during the POST don't get reset on success. On failure we
238
+ // restore the snapshot to the FRONT of the buffer (preserving order
239
+ // and prioritizing the originating-error context). The `maxBuffer`
240
+ // cap on the `log()` path still bounds total memory if the endpoint
241
+ // stays down — newest records are dropped via `onError("overflow")`.
242
+ const snapshot = buffer.splice(0, buffer.length);
243
+ const payload = serializeOtlp(snapshot, {
244
+ resourceAttributes,
245
+ scopeName,
246
+ scopeVersion,
247
+ });
248
+
249
+ const restoreOnFailure = () => {
250
+ // The snapshot was buffered BEFORE any records that landed during
251
+ // the failed POST — its contents are older and more likely to be
252
+ // the originating-error context operators care about. Restore
253
+ // policy preserves the snapshot first, evicts the NEWEST records
254
+ // when the cap is tight:
255
+ //
256
+ // 1. If snapshot + current buffer fit under the cap → unshift,
257
+ // done.
258
+ // 2. Otherwise, drop newest-tail records from the current buffer
259
+ // (records that arrived during the failed POST). If that
260
+ // suffices, unshift the full snapshot.
261
+ // 3. Only if the snapshot ALONE exceeds the cap do we truncate
262
+ // the snapshot — and even then, we keep its OLDEST end
263
+ // (`snapshot.length = maxBuffer`) and drop the newest tail.
264
+ //
265
+ // This is the inverse of the original (buggy) restore which kept
266
+ // newer records by truncating the older snapshot.
267
+ const total = snapshot.length + buffer.length;
268
+ if (total <= maxBuffer) {
269
+ buffer.unshift(...snapshot);
270
+ return;
271
+ }
272
+
273
+ let overflow = total - maxBuffer;
274
+ let droppedFromBuffer = 0;
275
+ if (buffer.length > 0 && overflow > 0) {
276
+ droppedFromBuffer = Math.min(buffer.length, overflow);
277
+ buffer.length -= droppedFromBuffer;
278
+ overflow -= droppedFromBuffer;
279
+ }
280
+ let droppedFromSnapshot = 0;
281
+ if (overflow > 0) {
282
+ // Snapshot alone exceeds the cap. Keep the oldest `maxBuffer`
283
+ // entries and drop the rest from the newest tail.
284
+ droppedFromSnapshot = Math.min(snapshot.length, overflow);
285
+ snapshot.length -= droppedFromSnapshot;
286
+ }
287
+ buffer.unshift(...snapshot);
288
+
289
+ const parts = [`dropped ${droppedFromBuffer} newer records from buffer`];
290
+ if (droppedFromSnapshot > 0) {
291
+ parts.push(`${droppedFromSnapshot} newest-tail records from snapshot`);
292
+ }
293
+ onError?.(
294
+ "overflow",
295
+ new Error(
296
+ `error-log buffer overflow on restore — ${parts.join(" and ")} (preserved ${snapshot.length} originating-error records)`,
297
+ ),
298
+ );
299
+ };
300
+
301
+ const controller = new AbortController();
302
+ const timer = setTimeout(() => controller.abort(), flushTimeoutMs);
303
+ try {
304
+ const res = await fetchImpl(endpoint, {
305
+ method: "POST",
306
+ headers: { ...extraHeaders, "Content-Type": "application/json" },
307
+ body: JSON.stringify(payload),
308
+ signal: controller.signal,
309
+ });
310
+ if (!res.ok) {
311
+ try {
312
+ await res.text();
313
+ } catch {
314
+ /* swallow */
315
+ }
316
+ restoreOnFailure();
317
+ onError?.("flush", new Error(`POST ${endpoint} → ${res.status}`));
318
+ }
319
+ } catch (err) {
320
+ restoreOnFailure();
321
+ onError?.("flush", err);
322
+ } finally {
323
+ clearTimeout(timer);
324
+ }
325
+ }
326
+
327
+ async function flush(): Promise<void> {
328
+ if (inflight) return inflight;
329
+
330
+ const elapsed = now() - lastFlushAt;
331
+ const overCap = buffer.length >= maxBuffer;
332
+ if (!overCap && elapsed < minFlushIntervalMs) return;
333
+
334
+ inflight = doFlush().finally(() => {
335
+ lastFlushAt = now();
336
+ inflight = null;
337
+ });
338
+ return inflight;
339
+ }
340
+
341
+ return {
342
+ adapter,
343
+ flush,
344
+ pendingRecordCount: () => buffer.length,
345
+ };
346
+ }
347
+
348
+ // ---------------------------------------------------------------------------
349
+ // OTLP/HTTP JSON serialization
350
+ // ---------------------------------------------------------------------------
351
+
352
+ function msToNs(ms: number): string {
353
+ return `${Math.floor(ms)}000000`;
354
+ }
355
+
356
+ function attrToOtlpValue(
357
+ v: unknown,
358
+ ):
359
+ | { stringValue: string }
360
+ | { intValue: string }
361
+ | { doubleValue: number }
362
+ | { boolValue: boolean } {
363
+ if (typeof v === "string") return { stringValue: v };
364
+ if (typeof v === "boolean") return { boolValue: v };
365
+ if (typeof v === "number" && Number.isFinite(v)) {
366
+ if (Number.isInteger(v)) return { intValue: String(v) };
367
+ return { doubleValue: v };
368
+ }
369
+ // Fallback — JSON-stringify so structured attrs round-trip as strings.
370
+ // `JSON.stringify` returns `undefined` for `undefined`, functions, and
371
+ // symbols. OTLP requires `stringValue` to be a string, so coerce with
372
+ // `String(v)` whenever serialization yields nothing parseable.
373
+ try {
374
+ const s = JSON.stringify(v);
375
+ return { stringValue: typeof s === "string" ? s : String(v) };
376
+ } catch {
377
+ return { stringValue: String(v) };
378
+ }
379
+ }
380
+
381
+ interface SerializeOpts {
382
+ resourceAttributes: Record<string, string>;
383
+ scopeName: string;
384
+ scopeVersion: string;
385
+ }
386
+
387
+ function serializeOtlp(records: PendingRecord[], opts: SerializeOpts): { resourceLogs: unknown[] } {
388
+ const otlpRecords = records.map((r) => ({
389
+ timeUnixNano: r.timeUnixNano,
390
+ observedTimeUnixNano: r.observedTimeUnixNano,
391
+ severityNumber: r.severityNumber,
392
+ severityText: r.severityText,
393
+ body: { stringValue: r.body },
394
+ attributes: Object.entries(r.attributes)
395
+ .filter(([_, v]) => v !== undefined && v !== null)
396
+ .map(([k, v]) => ({ key: k, value: attrToOtlpValue(v) })),
397
+ ...(r.traceId ? { traceId: r.traceId } : {}),
398
+ ...(r.spanId ? { spanId: r.spanId } : {}),
399
+ ...(r.traceFlags ? { flags: r.traceFlags } : {}),
400
+ }));
401
+
402
+ const resourceAttrs = Object.entries(opts.resourceAttributes)
403
+ .sort(([a], [b]) => a.localeCompare(b))
404
+ .map(([k, v]) => ({ key: k, value: { stringValue: v } }));
405
+
406
+ return {
407
+ resourceLogs: [
408
+ {
409
+ resource: { attributes: resourceAttrs },
410
+ scopeLogs: [
411
+ {
412
+ scope: { name: opts.scopeName, version: opts.scopeVersion },
413
+ logRecords: otlpRecords,
414
+ },
415
+ ],
416
+ },
417
+ ],
418
+ };
419
+ }