@blamejs/core 0.7.102 → 0.7.103

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.
@@ -0,0 +1,351 @@
1
+ "use strict";
2
+ /**
3
+ * b.observability.otlpExporter — OTLP/HTTP JSON span exporter.
4
+ *
5
+ * Buffers spans produced by b.observability.tracer and ships them to
6
+ * an OTLP-compatible HTTP collector. Implements the OpenTelemetry
7
+ * Protocol (OTLP) §3 trace export wire shape over HTTP/JSON
8
+ * (https://opentelemetry.io/docs/specs/otlp/#otlphttp).
9
+ *
10
+ * var tracer = b.observability.tracer.create({ service: "api" });
11
+ * var exporter = b.observability.otlpExporter.create({
12
+ * endpoint: "https://collector.example.com/v1/traces",
13
+ * headers: { "Authorization": "Bearer ..." },
14
+ * batchSize: 200,
15
+ * flushIntervalMs: C.TIME.seconds(5),
16
+ * maxQueueSize: 4096,
17
+ * });
18
+ * tracer.start("...", { onEnd: exporter.queue });
19
+ * ...
20
+ * await exporter.shutdown();
21
+ *
22
+ * Failure modes:
23
+ * - HTTP 5xx / network failure → exponential-backoff retry, up to
24
+ * maxAttempts (default 3); spans dropped after that
25
+ * - Queue overflow → drops oldest unexported spans, increments the
26
+ * drop counter
27
+ * - Endpoint unreachable at boot → exporter is still constructed;
28
+ * queue retries on every flush tick
29
+ *
30
+ * Wire shape: OTLP/JSON resourceSpans envelope per §3.4.
31
+ */
32
+
33
+ var C = require("./constants");
34
+ var lazyRequire = require("./lazy-require");
35
+ var safeAsync = require("./safe-async");
36
+ var safeBuffer = require("./safe-buffer");
37
+ var validateOpts = require("./validate-opts");
38
+ var safeUrl = require("./safe-url");
39
+ var { defineClass } = require("./framework-error");
40
+
41
+ var OtlpExporterError = defineClass("OtlpExporterError", { alwaysPermanent: true });
42
+
43
+ var observability = lazyRequire(function () { return require("./observability"); });
44
+
45
+ var DEFAULT_BATCH_SIZE = 200; // allow:raw-byte-literal — OTLP recommended batch
46
+ var DEFAULT_MAX_QUEUE_SIZE = 4096; // allow:raw-byte-literal — operator-side queue cap
47
+ var DEFAULT_FLUSH_INTERVAL_MS = C.TIME.seconds(5);
48
+ var DEFAULT_MAX_ATTEMPTS = 3; // allow:raw-byte-literal — retry attempt count
49
+ var DEFAULT_BACKOFF_INITIAL_MS = C.TIME.seconds(1);
50
+ var DEFAULT_BACKOFF_MAX_MS = C.TIME.seconds(30);
51
+ var DEFAULT_TIMEOUT_MS = C.TIME.seconds(30);
52
+
53
+ // OTLP severity numbers per §3.5 (logs); not used for traces but
54
+ // retained as a reference for future log-export support.
55
+ var STATUS_CODE_TO_OTLP = Object.freeze({
56
+ unset: 0, // allow:raw-byte-literal — OTLP STATUS_CODE_UNSET enum
57
+ ok: 1, // allow:raw-byte-literal — OTLP STATUS_CODE_OK enum
58
+ error: 2, // allow:raw-byte-literal — OTLP STATUS_CODE_ERROR enum
59
+ });
60
+
61
+ var KIND_TO_OTLP = Object.freeze({
62
+ internal: 1, // allow:raw-byte-literal — OTLP SPAN_KIND_INTERNAL
63
+ server: 2, // allow:raw-byte-literal — OTLP SPAN_KIND_SERVER
64
+ client: 3, // allow:raw-byte-literal — OTLP SPAN_KIND_CLIENT
65
+ producer: 4, // allow:raw-byte-literal — OTLP SPAN_KIND_PRODUCER
66
+ consumer: 5, // allow:raw-byte-literal — OTLP SPAN_KIND_CONSUMER
67
+ });
68
+
69
+ function _attrToOtlp(attrs) {
70
+ // OTLP attribute shape: [{ key, value: { stringValue | intValue |
71
+ // doubleValue | boolValue | arrayValue: { values: [...] } } }, ...]
72
+ var out = [];
73
+ if (!attrs || typeof attrs !== "object") return out;
74
+ var keys = Object.keys(attrs);
75
+ for (var i = 0; i < keys.length; i++) {
76
+ var k = keys[i];
77
+ var v = attrs[k];
78
+ out.push({ key: k, value: _valueToOtlp(v) });
79
+ }
80
+ return out;
81
+ }
82
+
83
+ function _valueToOtlp(v) {
84
+ var t = typeof v;
85
+ if (t === "string") return { stringValue: v };
86
+ if (t === "boolean") return { boolValue: v };
87
+ if (t === "number") {
88
+ if (Number.isInteger(v)) return { intValue: String(v) };
89
+ return { doubleValue: v };
90
+ }
91
+ if (Array.isArray(v)) {
92
+ return {
93
+ arrayValue: {
94
+ values: v.map(function (el) { return _valueToOtlp(el); }),
95
+ },
96
+ };
97
+ }
98
+ return { stringValue: String(v) };
99
+ }
100
+
101
+ function _spanToOtlp(span) {
102
+ return {
103
+ traceId: span.traceId,
104
+ spanId: span.spanId,
105
+ parentSpanId: span.parentSpanId || "",
106
+ name: span.name,
107
+ kind: KIND_TO_OTLP[span.kind] || KIND_TO_OTLP.internal,
108
+ startTimeUnixNano: span.startTimeUnixNano,
109
+ endTimeUnixNano: span.endTimeUnixNano || span.startTimeUnixNano,
110
+ attributes: _attrToOtlp(span.attributes),
111
+ droppedAttributesCount: span.droppedAttributesCount || 0,
112
+ events: (span.events || []).map(function (e) {
113
+ return {
114
+ name: e.name,
115
+ timeUnixNano: e.timeUnixNano,
116
+ attributes: _attrToOtlp(e.attributes),
117
+ droppedAttributesCount: 0,
118
+ };
119
+ }),
120
+ droppedEventsCount: span.droppedEventsCount || 0,
121
+ status: {
122
+ code: STATUS_CODE_TO_OTLP[span.status && span.status.code] || 0,
123
+ message: (span.status && span.status.message) || "",
124
+ },
125
+ };
126
+ }
127
+
128
+ function _bundleSpans(spans) {
129
+ // Group spans by resource → OTLP resourceSpans envelope. Spans that
130
+ // share the same resource attributes get bundled together.
131
+ if (spans.length === 0) return { resourceSpans: [] };
132
+ var byResource = new Map();
133
+ for (var i = 0; i < spans.length; i++) {
134
+ var s = spans[i];
135
+ var resKey = JSON.stringify(s.resource || {});
136
+ var bucket = byResource.get(resKey);
137
+ if (!bucket) {
138
+ bucket = {
139
+ resource: s.resource || {},
140
+ scope: s.scope || { name: "blamejs", version: null },
141
+ spans: [],
142
+ };
143
+ byResource.set(resKey, bucket);
144
+ }
145
+ bucket.spans.push(s);
146
+ }
147
+ var resourceSpans = [];
148
+ for (var entry of byResource) {
149
+ var b = entry[1];
150
+ resourceSpans.push({
151
+ resource: { attributes: _attrToOtlp(b.resource) },
152
+ scopeSpans: [{
153
+ scope: {
154
+ name: b.scope.name,
155
+ version: b.scope.version || "",
156
+ },
157
+ spans: b.spans.map(_spanToOtlp),
158
+ }],
159
+ });
160
+ }
161
+ return { resourceSpans: resourceSpans };
162
+ }
163
+
164
+ function create(opts) {
165
+ validateOpts.requireObject(opts, "otlpExporter", OtlpExporterError);
166
+ validateOpts(opts, [
167
+ "endpoint", "headers", "batchSize", "maxQueueSize",
168
+ "flushIntervalMs", "timeoutMs", "maxAttempts",
169
+ "backoffInitialMs", "backoffMaxMs",
170
+ "fetchImpl", "audit", "allowedProtocols",
171
+ ], "otlpExporter.create");
172
+ validateOpts.requireNonEmptyString(opts.endpoint,
173
+ "otlpExporter.create: endpoint", OtlpExporterError, "otlp/bad-endpoint");
174
+ // Validate that endpoint is an http(s) URL via the framework's safe-url.
175
+ // Operators using cleartext (e.g. localhost dev collector) opt in to
176
+ // ALLOW_HTTP_ALL; production deployments leave the default which
177
+ // requires HTTPS for outbound telemetry.
178
+ var allowedProtocols = opts.allowedProtocols || safeUrl.ALLOW_HTTPS_ONLY;
179
+ try { safeUrl.parse(opts.endpoint, { allowedProtocols: allowedProtocols }); }
180
+ catch (e) {
181
+ throw new OtlpExporterError("otlp/bad-endpoint",
182
+ "otlpExporter.create: endpoint must be a valid URL: " + e.message);
183
+ }
184
+
185
+ validateOpts.optionalPositiveFinite(opts.batchSize,
186
+ "otlpExporter.create: batchSize", OtlpExporterError, "otlp/bad-opts");
187
+ validateOpts.optionalPositiveFinite(opts.maxQueueSize,
188
+ "otlpExporter.create: maxQueueSize", OtlpExporterError, "otlp/bad-opts");
189
+ if (opts.flushIntervalMs !== undefined && opts.flushIntervalMs !== 0) {
190
+ validateOpts.optionalPositiveFinite(opts.flushIntervalMs,
191
+ "otlpExporter.create: flushIntervalMs", OtlpExporterError, "otlp/bad-opts");
192
+ }
193
+ validateOpts.optionalPositiveFinite(opts.timeoutMs,
194
+ "otlpExporter.create: timeoutMs", OtlpExporterError, "otlp/bad-opts");
195
+ validateOpts.optionalPositiveFinite(opts.maxAttempts,
196
+ "otlpExporter.create: maxAttempts", OtlpExporterError, "otlp/bad-opts");
197
+
198
+ var endpoint = opts.endpoint;
199
+ var headers = Object.assign({
200
+ "Content-Type": "application/json",
201
+ }, opts.headers || {});
202
+ var batchSize = opts.batchSize || DEFAULT_BATCH_SIZE;
203
+ var maxQueue = opts.maxQueueSize || DEFAULT_MAX_QUEUE_SIZE;
204
+ var flushIntervalMs = opts.flushIntervalMs || DEFAULT_FLUSH_INTERVAL_MS;
205
+ var timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
206
+ var maxAttempts = opts.maxAttempts || DEFAULT_MAX_ATTEMPTS;
207
+ var backoffInitial = opts.backoffInitialMs || DEFAULT_BACKOFF_INITIAL_MS;
208
+ var backoffMax = opts.backoffMaxMs || DEFAULT_BACKOFF_MAX_MS;
209
+ var fetchImpl = opts.fetchImpl || ((typeof globalThis.fetch === "function") ? globalThis.fetch.bind(globalThis) : null);
210
+ if (typeof fetchImpl !== "function") {
211
+ throw new OtlpExporterError("otlp/no-fetch",
212
+ "otlpExporter.create: fetchImpl required (globalThis.fetch unavailable)");
213
+ }
214
+
215
+ var queue = [];
216
+ var droppedQueueOverflow = 0;
217
+ var droppedExportFailed = 0;
218
+ var inFlight = false;
219
+ var stopping = false;
220
+
221
+ function _emitMetric(verb, n, labels) {
222
+ try { observability().safeEvent("otlp.exporter." + verb, n || 1, labels || {}); }
223
+ catch (_e) { /* drop-silent */ }
224
+ }
225
+
226
+ function queue_(span) {
227
+ if (stopping) { droppedExportFailed += 1; return; }
228
+ if (!span || typeof span !== "object") return;
229
+ if (queue.length >= maxQueue) {
230
+ // Drop oldest, append newest — keeps the most-recent telemetry.
231
+ queue.shift();
232
+ droppedQueueOverflow += 1;
233
+ _emitMetric("queue_overflow", 1, {});
234
+ }
235
+ queue.push(span);
236
+ if (queue.length >= batchSize) {
237
+ // Best-effort flush; don't block the caller.
238
+ flush().catch(function () { /* drop-silent */ });
239
+ }
240
+ }
241
+
242
+ function _backoffMs(attempt) {
243
+ var ms = backoffInitial * Math.pow(2, Math.max(0, attempt - 1)); // allow:raw-byte-literal — exponential factor
244
+ return Math.min(ms, backoffMax);
245
+ }
246
+
247
+ function _sleep(ms) {
248
+ return safeAsync.sleep(ms);
249
+ }
250
+
251
+ async function _post(payload, attempt) {
252
+ attempt = attempt || 1;
253
+ var ac = (typeof AbortController === "function") ? new AbortController() : null;
254
+ var t = ac ? setTimeout(function () { ac.abort(); }, timeoutMs) : null;
255
+ try {
256
+ var res = await fetchImpl(endpoint, {
257
+ method: "POST",
258
+ headers: headers,
259
+ body: JSON.stringify(payload),
260
+ signal: ac ? ac.signal : undefined,
261
+ });
262
+ if (res && res.ok) return { ok: true, status: res.status };
263
+ var status = res && res.status;
264
+ // 5xx + 408/429 → retryable; everything else permanent
265
+ var retryable = (status >= 500 && status < 600) || status === 408 || status === 429; // allow:raw-byte-literal — HTTP status ranges
266
+ if (retryable && attempt < maxAttempts) {
267
+ await _sleep(_backoffMs(attempt));
268
+ return await _post(payload, attempt + 1);
269
+ }
270
+ return { ok: false, status: status, retryable: retryable };
271
+ } catch (e) {
272
+ // Network error / abort
273
+ if (attempt < maxAttempts) {
274
+ await _sleep(_backoffMs(attempt));
275
+ return await _post(payload, attempt + 1);
276
+ }
277
+ return { ok: false, error: (e && e.message) || String(e), retryable: true };
278
+ } finally {
279
+ if (t) clearTimeout(t);
280
+ }
281
+ }
282
+
283
+ async function flush() {
284
+ if (inFlight) return { sent: 0, skipped: true };
285
+ if (queue.length === 0) return { sent: 0 };
286
+ inFlight = true;
287
+ try {
288
+ var batch = queue.splice(0, batchSize);
289
+ var payload = _bundleSpans(batch);
290
+ var result = await _post(payload, 1);
291
+ if (result.ok) {
292
+ _emitMetric("export_ok", batch.length, { http_status: String(result.status) });
293
+ return { sent: batch.length };
294
+ }
295
+ droppedExportFailed += batch.length;
296
+ _emitMetric("export_failed", batch.length, {
297
+ http_status: String(result.status || "network"),
298
+ });
299
+ return { sent: 0, dropped: batch.length };
300
+ } finally {
301
+ inFlight = false;
302
+ }
303
+ }
304
+
305
+ // Periodic flush worker
306
+ var ticker = null;
307
+ if (flushIntervalMs > 0) {
308
+ ticker = safeAsync.repeating(function () {
309
+ flush().catch(function () { /* drop-silent */ });
310
+ }, flushIntervalMs, { name: "otlp-exporter-flush" });
311
+ }
312
+
313
+ async function shutdown() {
314
+ stopping = true;
315
+ if (ticker) { ticker.stop(); ticker = null; }
316
+ // Drain remaining spans, best-effort
317
+ while (queue.length > 0) {
318
+ var r = await flush();
319
+ if (!r || r.sent === 0) break;
320
+ }
321
+ }
322
+
323
+ function stats() {
324
+ return {
325
+ queueLength: queue.length,
326
+ droppedQueueOverflow: droppedQueueOverflow,
327
+ droppedExportFailed: droppedExportFailed,
328
+ };
329
+ }
330
+
331
+ return {
332
+ queue: queue_,
333
+ flush: flush,
334
+ shutdown: shutdown,
335
+ stats: stats,
336
+ // Internal hook for tests
337
+ _bundleForTest: _bundleSpans,
338
+ };
339
+ }
340
+
341
+ module.exports = {
342
+ create: create,
343
+ STATUS_CODE_TO_OTLP: STATUS_CODE_TO_OTLP,
344
+ KIND_TO_OTLP: KIND_TO_OTLP,
345
+ OtlpExporterError: OtlpExporterError,
346
+ // Exported for tests
347
+ _spanToOtlp: _spanToOtlp,
348
+ _bundleSpans: _bundleSpans,
349
+ _attrToOtlp: _attrToOtlp,
350
+ _BASE64URL_RE_REF: safeBuffer.BASE64URL_RE, // not used; reserved for OTLP/protobuf shape upgrade
351
+ };