@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,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
|
+
}
|