@blamejs/core 0.5.14 → 0.5.16
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/CHANGELOG.md +2 -0
- package/index.js +4 -0
- package/lib/archive.js +241 -0
- package/lib/otel-export.js +272 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -8,6 +8,8 @@ upgrading across more than a few patches at a time.
|
|
|
8
8
|
|
|
9
9
|
## v0.5.x
|
|
10
10
|
|
|
11
|
+
- **0.5.15** (2026-04-30) — b.archive: ZIP creation
|
|
12
|
+
- **0.5.14** (2026-04-30) — b.time: timezone-aware datetime arithmetic + formatting
|
|
11
13
|
- **0.5.13** (2026-04-30) — b.testing.request: supertest-style chainable HTTP test helper
|
|
12
14
|
- **0.5.12** (2026-04-30) — b.middleware.requestLog: HTTP access-log middleware
|
|
13
15
|
- **0.5.11** (2026-04-30) — b.config: schema-validated environment configuration
|
package/index.js
CHANGED
|
@@ -105,6 +105,7 @@ var staticServe = require("./lib/static");
|
|
|
105
105
|
var forms = require("./lib/forms");
|
|
106
106
|
var app = require("./lib/app");
|
|
107
107
|
var jobs = require("./lib/jobs");
|
|
108
|
+
var archive = require("./lib/archive");
|
|
108
109
|
var breakGlass = require("./lib/break-glass");
|
|
109
110
|
var config = require("./lib/config");
|
|
110
111
|
var csv = require("./lib/csv");
|
|
@@ -144,6 +145,7 @@ var pagination = require("./lib/pagination");
|
|
|
144
145
|
var metrics = require("./lib/metrics");
|
|
145
146
|
var tracing = require("./lib/tracing");
|
|
146
147
|
var observability = require("./lib/observability");
|
|
148
|
+
var otelExport = require("./lib/otel-export");
|
|
147
149
|
var protocolDispatcher = require("./lib/protocol-dispatcher");
|
|
148
150
|
var requestHelpers = require("./lib/request-helpers");
|
|
149
151
|
var appShutdown = require("./lib/app-shutdown");
|
|
@@ -211,6 +213,7 @@ module.exports = {
|
|
|
211
213
|
forms: forms,
|
|
212
214
|
createApp: app.createApp,
|
|
213
215
|
jobs: jobs,
|
|
216
|
+
archive: archive,
|
|
214
217
|
breakGlass: breakGlass,
|
|
215
218
|
config: config,
|
|
216
219
|
csv: csv,
|
|
@@ -249,6 +252,7 @@ module.exports = {
|
|
|
249
252
|
metrics: metrics,
|
|
250
253
|
tracing: tracing,
|
|
251
254
|
observability: observability,
|
|
255
|
+
otelExport: otelExport,
|
|
252
256
|
protocolDispatcher: protocolDispatcher,
|
|
253
257
|
requestHelpers: requestHelpers,
|
|
254
258
|
appShutdown: appShutdown,
|
package/lib/archive.js
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* archive — ZIP creation. Operator-data-export shape ("download my
|
|
4
|
+
* data as a zip"), log archives, plain-zip exports for users.
|
|
5
|
+
*
|
|
6
|
+
* var archive = b.archive.zip();
|
|
7
|
+
* archive.addFile("readme.txt", "Hello\n");
|
|
8
|
+
* archive.addFile("data/users.csv", csvBytes, { method: "deflate" });
|
|
9
|
+
* archive.addFile("avatars/me.png", pngBuf, { method: "store" }); // already-compressed
|
|
10
|
+
* var zipBytes = archive.toBuffer();
|
|
11
|
+
*
|
|
12
|
+
* // OR write directly to disk:
|
|
13
|
+
* archive.writeTo("/tmp/export.zip");
|
|
14
|
+
*
|
|
15
|
+
* Format support:
|
|
16
|
+
* - Stored (no compression — for already-compressed inputs like
|
|
17
|
+
* PNG / JPEG / mp4)
|
|
18
|
+
* - Deflate via node:zlib's deflateRawSync (default for everything else)
|
|
19
|
+
* - File names with / are honored — directory entries are implicit;
|
|
20
|
+
* extractors create the directory structure on demand
|
|
21
|
+
* - UTF-8 file names (sets the EFS bit per APPNOTE 6.3.4)
|
|
22
|
+
* - Modification time defaults to "now"; operators override per file
|
|
23
|
+
*
|
|
24
|
+
* v1 scope cuts (deferred):
|
|
25
|
+
* - ZIP64 (>4 GiB archives, >65535 files) — operators with that
|
|
26
|
+
* scale bring their own
|
|
27
|
+
* - Encryption — `b.crypto.encryptPacked` produces a sealed bundle
|
|
28
|
+
* for the operator's encryption-at-rest needs; ZIP-native
|
|
29
|
+
* password encryption is broken-by-design
|
|
30
|
+
* - Streaming write (toStream) — toBuffer() is enough for the
|
|
31
|
+
* "download my data" shape; operators streaming gigabytes
|
|
32
|
+
* have a different toolset
|
|
33
|
+
* - Reading / extraction — write-only for now
|
|
34
|
+
*/
|
|
35
|
+
var zlib = require("node:zlib");
|
|
36
|
+
var fs = require("node:fs");
|
|
37
|
+
var nodeCrypto = require("node:crypto");
|
|
38
|
+
var { defineClass } = require("./framework-error");
|
|
39
|
+
|
|
40
|
+
var ArchiveError = defineClass("ArchiveError", { alwaysPermanent: true });
|
|
41
|
+
|
|
42
|
+
// ZIP signatures
|
|
43
|
+
var SIG_LFH = 0x04034b50; // local file header
|
|
44
|
+
var SIG_CFH = 0x02014b50; // central directory file header
|
|
45
|
+
var SIG_EOCD = 0x06054b50; // end of central directory
|
|
46
|
+
|
|
47
|
+
// Compression methods
|
|
48
|
+
var METHOD_STORE = 0;
|
|
49
|
+
var METHOD_DEFLATE = 8;
|
|
50
|
+
|
|
51
|
+
// CRC-32 — IEEE 802.3 polynomial. node:crypto has no native CRC32, so
|
|
52
|
+
// we vendor the standard table-driven implementation.
|
|
53
|
+
var CRC32_TABLE = (function () {
|
|
54
|
+
var t = new Uint32Array(256);
|
|
55
|
+
for (var i = 0; i < 256; i++) {
|
|
56
|
+
var c = i;
|
|
57
|
+
for (var j = 0; j < 8; j++) c = (c & 1) ? (0xedb88320 ^ (c >>> 1)) : (c >>> 1);
|
|
58
|
+
t[i] = c >>> 0;
|
|
59
|
+
}
|
|
60
|
+
return t;
|
|
61
|
+
})();
|
|
62
|
+
|
|
63
|
+
function _crc32(buf) {
|
|
64
|
+
var crc = 0xffffffff;
|
|
65
|
+
for (var i = 0; i < buf.length; i++) {
|
|
66
|
+
crc = CRC32_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
|
|
67
|
+
}
|
|
68
|
+
return (crc ^ 0xffffffff) >>> 0;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// MS-DOS date/time encoding — APPNOTE 4.4.6
|
|
72
|
+
function _msdosDateTime(date) {
|
|
73
|
+
var d = date instanceof Date ? date : new Date(date);
|
|
74
|
+
if (isNaN(d.getTime())) d = new Date();
|
|
75
|
+
var dosTime = ((d.getHours() & 0x1f) << 11) |
|
|
76
|
+
((d.getMinutes() & 0x3f) << 5) |
|
|
77
|
+
((Math.floor(d.getSeconds() / 2)) & 0x1f);
|
|
78
|
+
var dosDate = (((d.getFullYear() - 1980) & 0x7f) << 9) |
|
|
79
|
+
(((d.getMonth() + 1) & 0xf) << 5) |
|
|
80
|
+
(d.getDate() & 0x1f);
|
|
81
|
+
return { time: dosTime, date: dosDate };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function zip() {
|
|
85
|
+
var entries = [];
|
|
86
|
+
|
|
87
|
+
function addFile(name, content, opts) {
|
|
88
|
+
if (typeof name !== "string" || name.length === 0) {
|
|
89
|
+
throw new ArchiveError("archive/bad-name", "addFile: name must be a non-empty string");
|
|
90
|
+
}
|
|
91
|
+
if (name.indexOf("\0") !== -1) {
|
|
92
|
+
throw new ArchiveError("archive/bad-name", "addFile: name contains null byte");
|
|
93
|
+
}
|
|
94
|
+
// No path traversal — relative paths only, no leading slash, no ".." segments.
|
|
95
|
+
var normalized = name.replace(/\\/g, "/").replace(/^\/+/, "");
|
|
96
|
+
var segs = normalized.split("/");
|
|
97
|
+
for (var si = 0; si < segs.length; si++) {
|
|
98
|
+
if (segs[si] === "..") {
|
|
99
|
+
throw new ArchiveError("archive/bad-name", "addFile: name contains '..' segment");
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
var bodyBuf;
|
|
103
|
+
if (Buffer.isBuffer(content)) bodyBuf = content;
|
|
104
|
+
else if (typeof content === "string") bodyBuf = Buffer.from(content, "utf8");
|
|
105
|
+
else throw new ArchiveError("archive/bad-content",
|
|
106
|
+
"addFile: content must be a Buffer or string, got " + typeof content);
|
|
107
|
+
|
|
108
|
+
opts = opts || {};
|
|
109
|
+
var method = opts.method === "store" ? METHOD_STORE : METHOD_DEFLATE;
|
|
110
|
+
var mtime = opts.mtime instanceof Date ? opts.mtime : new Date();
|
|
111
|
+
|
|
112
|
+
var crc = _crc32(bodyBuf);
|
|
113
|
+
var stored = bodyBuf;
|
|
114
|
+
if (method === METHOD_DEFLATE) {
|
|
115
|
+
stored = zlib.deflateRawSync(bodyBuf);
|
|
116
|
+
// If deflate didn't shrink it (small/already-compressed inputs),
|
|
117
|
+
// fall back to STORE to save the operator a few bytes.
|
|
118
|
+
if (stored.length >= bodyBuf.length) {
|
|
119
|
+
stored = bodyBuf;
|
|
120
|
+
method = METHOD_STORE;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
entries.push({
|
|
125
|
+
name: normalized,
|
|
126
|
+
method: method,
|
|
127
|
+
mtime: mtime,
|
|
128
|
+
crc: crc,
|
|
129
|
+
stored: stored,
|
|
130
|
+
uncompressedSize: bodyBuf.length,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _buildLocalFileHeader(entry) {
|
|
135
|
+
var nameBuf = Buffer.from(entry.name, "utf8");
|
|
136
|
+
var dt = _msdosDateTime(entry.mtime);
|
|
137
|
+
var hdr = Buffer.alloc(30);
|
|
138
|
+
hdr.writeUInt32LE(SIG_LFH, 0);
|
|
139
|
+
hdr.writeUInt16LE(20, 4); // version needed
|
|
140
|
+
hdr.writeUInt16LE(0x0800, 6); // flags: bit 11 = UTF-8 name
|
|
141
|
+
hdr.writeUInt16LE(entry.method, 8);
|
|
142
|
+
hdr.writeUInt16LE(dt.time, 10);
|
|
143
|
+
hdr.writeUInt16LE(dt.date, 12);
|
|
144
|
+
hdr.writeUInt32LE(entry.crc, 14);
|
|
145
|
+
hdr.writeUInt32LE(entry.stored.length, 18);
|
|
146
|
+
hdr.writeUInt32LE(entry.uncompressedSize, 22);
|
|
147
|
+
hdr.writeUInt16LE(nameBuf.length, 26);
|
|
148
|
+
hdr.writeUInt16LE(0, 28); // extra field length
|
|
149
|
+
return Buffer.concat([hdr, nameBuf]);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function _buildCentralDirectoryEntry(entry, lfhOffset) {
|
|
153
|
+
var nameBuf = Buffer.from(entry.name, "utf8");
|
|
154
|
+
var dt = _msdosDateTime(entry.mtime);
|
|
155
|
+
var hdr = Buffer.alloc(46);
|
|
156
|
+
hdr.writeUInt32LE(SIG_CFH, 0);
|
|
157
|
+
hdr.writeUInt16LE(0x033f, 4); // version made by (UNIX | 6.3)
|
|
158
|
+
hdr.writeUInt16LE(20, 6); // version needed
|
|
159
|
+
hdr.writeUInt16LE(0x0800, 8); // flags: bit 11 = UTF-8
|
|
160
|
+
hdr.writeUInt16LE(entry.method, 10);
|
|
161
|
+
hdr.writeUInt16LE(dt.time, 12);
|
|
162
|
+
hdr.writeUInt16LE(dt.date, 14);
|
|
163
|
+
hdr.writeUInt32LE(entry.crc, 16);
|
|
164
|
+
hdr.writeUInt32LE(entry.stored.length, 20);
|
|
165
|
+
hdr.writeUInt32LE(entry.uncompressedSize, 24);
|
|
166
|
+
hdr.writeUInt16LE(nameBuf.length, 28);
|
|
167
|
+
hdr.writeUInt16LE(0, 30); // extra field length
|
|
168
|
+
hdr.writeUInt16LE(0, 32); // file comment length
|
|
169
|
+
hdr.writeUInt16LE(0, 34); // disk number start
|
|
170
|
+
hdr.writeUInt16LE(0, 36); // internal file attributes
|
|
171
|
+
hdr.writeUInt32LE(0, 38); // external file attributes
|
|
172
|
+
hdr.writeUInt32LE(lfhOffset, 42);
|
|
173
|
+
return Buffer.concat([hdr, nameBuf]);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function toBuffer() {
|
|
177
|
+
if (entries.length > 65535) {
|
|
178
|
+
throw new ArchiveError("archive/too-many-entries",
|
|
179
|
+
"ZIP archive cannot contain more than 65535 entries (ZIP64 unsupported in v1)");
|
|
180
|
+
}
|
|
181
|
+
var pieces = [];
|
|
182
|
+
var offsets = [];
|
|
183
|
+
var totalLocalBytes = 0;
|
|
184
|
+
for (var i = 0; i < entries.length; i++) {
|
|
185
|
+
offsets.push(totalLocalBytes);
|
|
186
|
+
var lfh = _buildLocalFileHeader(entries[i]);
|
|
187
|
+
pieces.push(lfh);
|
|
188
|
+
pieces.push(entries[i].stored);
|
|
189
|
+
totalLocalBytes += lfh.length + entries[i].stored.length;
|
|
190
|
+
}
|
|
191
|
+
var cdStart = totalLocalBytes;
|
|
192
|
+
var cdSize = 0;
|
|
193
|
+
for (var j = 0; j < entries.length; j++) {
|
|
194
|
+
var cdh = _buildCentralDirectoryEntry(entries[j], offsets[j]);
|
|
195
|
+
pieces.push(cdh);
|
|
196
|
+
cdSize += cdh.length;
|
|
197
|
+
}
|
|
198
|
+
// End of Central Directory
|
|
199
|
+
var eocd = Buffer.alloc(22);
|
|
200
|
+
eocd.writeUInt32LE(SIG_EOCD, 0);
|
|
201
|
+
eocd.writeUInt16LE(0, 4); // disk number
|
|
202
|
+
eocd.writeUInt16LE(0, 6); // disk where CD starts
|
|
203
|
+
eocd.writeUInt16LE(entries.length, 8); // entries on this disk
|
|
204
|
+
eocd.writeUInt16LE(entries.length, 10); // total entries
|
|
205
|
+
eocd.writeUInt32LE(cdSize, 12); // size of central directory
|
|
206
|
+
eocd.writeUInt32LE(cdStart, 16); // offset of central directory
|
|
207
|
+
eocd.writeUInt16LE(0, 20); // comment length
|
|
208
|
+
pieces.push(eocd);
|
|
209
|
+
return Buffer.concat(pieces);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function writeTo(filepath) {
|
|
213
|
+
var buf = toBuffer();
|
|
214
|
+
fs.writeFileSync(filepath, buf);
|
|
215
|
+
return buf.length;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function digest() {
|
|
219
|
+
// SHA-256 of the produced archive bytes — useful for operator-side
|
|
220
|
+
// integrity logging on exported bundles. Not vendor-locked to any
|
|
221
|
+
// particular hash; SHA-256 is universally recognized for content
|
|
222
|
+
// addressing.
|
|
223
|
+
return nodeCrypto.createHash("sha256").update(toBuffer()).digest("hex");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
addFile: addFile,
|
|
228
|
+
toBuffer: toBuffer,
|
|
229
|
+
writeTo: writeTo,
|
|
230
|
+
digest: digest,
|
|
231
|
+
get entryCount() { return entries.length; },
|
|
232
|
+
};
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
module.exports = {
|
|
236
|
+
zip: zip,
|
|
237
|
+
ArchiveError: ArchiveError,
|
|
238
|
+
// Test-only export — operators don't call this; it's here for unit-testing
|
|
239
|
+
// the CRC implementation against known vectors.
|
|
240
|
+
_crc32ForTest: _crc32,
|
|
241
|
+
};
|
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* otel-export — OTLP/HTTP-JSON exporter for `b.observability` events.
|
|
4
|
+
*
|
|
5
|
+
* Bridges the framework's `observability.event(name, value, attrs)`
|
|
6
|
+
* surface to any OTel-compatible backend (Honeycomb, Datadog, Jaeger
|
|
7
|
+
* Collector, AWS Distro, Grafana, NewRelic — anything that speaks
|
|
8
|
+
* OTLP/HTTP). Spec: opentelemetry.io/docs/specs/otlp.
|
|
9
|
+
*
|
|
10
|
+
* var otel = b.otelExport.create({
|
|
11
|
+
* endpoint: "https://otel.honeycomb.io/v1/metrics",
|
|
12
|
+
* headers: { "X-Honeycomb-Team": env("HONEYCOMB_API_KEY") },
|
|
13
|
+
* serviceName: "wiki",
|
|
14
|
+
* intervalMs: b.constants.TIME.seconds(15), // auto-flush cadence
|
|
15
|
+
* httpClient: b.httpClient, // for testing
|
|
16
|
+
* });
|
|
17
|
+
*
|
|
18
|
+
* // Counter — accumulates per (name, attrs) tuple, flushed in batches.
|
|
19
|
+
* otel.recordCounter("http.requests", 1, { method: "GET", status: 200 });
|
|
20
|
+
*
|
|
21
|
+
* // Histogram — operator-bucketed observation. Less common; operators
|
|
22
|
+
* // wanting full histogram support build on top.
|
|
23
|
+
* otel.recordObservation("http.duration_ms", 142, { route: "/api/x" });
|
|
24
|
+
*
|
|
25
|
+
* await otel.flush(); // manual flush
|
|
26
|
+
* otel.close(); // cancels interval, final flush
|
|
27
|
+
*
|
|
28
|
+
* Wiring with `b.observability`:
|
|
29
|
+
*
|
|
30
|
+
* // Option A: replace the metrics tap entirely
|
|
31
|
+
* b.observability._setTap(otel.tapHandler);
|
|
32
|
+
*
|
|
33
|
+
* // Option B: alongside b.metrics — operators write their own
|
|
34
|
+
* // multi-tap fan-out (or pick one or the other for v1).
|
|
35
|
+
*
|
|
36
|
+
* Endpoint must accept OTLP/HTTP with Content-Type: application/json
|
|
37
|
+
* (the JSON variant of the OTLP protobuf — every modern collector
|
|
38
|
+
* supports it). The framework refuses to ship the binary protobuf
|
|
39
|
+
* encoding because it requires either a vendored proto runtime or
|
|
40
|
+
* hand-rolled wire format with no compelling benefit at this scope.
|
|
41
|
+
*/
|
|
42
|
+
var C = require("./constants");
|
|
43
|
+
var validateOpts = require("./validate-opts");
|
|
44
|
+
var { defineClass } = require("./framework-error");
|
|
45
|
+
|
|
46
|
+
var OtelExportError = defineClass("OtelExportError", { alwaysPermanent: false });
|
|
47
|
+
|
|
48
|
+
var DEFAULT_INTERVAL_MS = C.TIME.seconds(15);
|
|
49
|
+
|
|
50
|
+
// OTLP aggregation temporality:
|
|
51
|
+
// 1 = DELTA — counters report deltas since last export
|
|
52
|
+
// 2 = CUMULATIVE — counters report running totals
|
|
53
|
+
// DELTA is what most exporters do for short-lived processes; the
|
|
54
|
+
// receiving backend handles the running sum.
|
|
55
|
+
var TEMPORALITY_DELTA = 1;
|
|
56
|
+
|
|
57
|
+
// ---- attribute encoding ----
|
|
58
|
+
// OTLP attributes are KeyValue with typed `value` fields:
|
|
59
|
+
// { key, value: { stringValue | intValue | doubleValue | boolValue } }
|
|
60
|
+
function _attrsToOtlp(attrs) {
|
|
61
|
+
if (!attrs || typeof attrs !== "object") return [];
|
|
62
|
+
var out = [];
|
|
63
|
+
for (var k in attrs) {
|
|
64
|
+
if (!Object.prototype.hasOwnProperty.call(attrs, k)) continue;
|
|
65
|
+
var v = attrs[k];
|
|
66
|
+
var kv;
|
|
67
|
+
if (typeof v === "string") kv = { stringValue: v };
|
|
68
|
+
else if (typeof v === "number") {
|
|
69
|
+
kv = Number.isInteger(v) ? { intValue: String(v) } : { doubleValue: v };
|
|
70
|
+
}
|
|
71
|
+
else if (typeof v === "boolean") kv = { boolValue: v };
|
|
72
|
+
else if (v == null) continue;
|
|
73
|
+
else kv = { stringValue: String(v) };
|
|
74
|
+
out.push({ key: k, value: kv });
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Stable key per (name, attrs) so tap calls aggregate.
|
|
80
|
+
function _bucketKey(name, attrs) {
|
|
81
|
+
if (!attrs) return name + "|";
|
|
82
|
+
var ks = Object.keys(attrs).sort();
|
|
83
|
+
var parts = [name];
|
|
84
|
+
for (var i = 0; i < ks.length; i++) parts.push(ks[i] + "=" + String(attrs[ks[i]]));
|
|
85
|
+
return parts.join("|");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function create(opts) {
|
|
89
|
+
opts = opts || {};
|
|
90
|
+
validateOpts(opts, [
|
|
91
|
+
"endpoint", "headers", "serviceName", "intervalMs",
|
|
92
|
+
"httpClient", "resourceAttributes", "scope",
|
|
93
|
+
], "otelExport.create");
|
|
94
|
+
if (typeof opts.endpoint !== "string" || opts.endpoint.length === 0) {
|
|
95
|
+
throw new OtelExportError("otel-export/bad-endpoint",
|
|
96
|
+
"create: endpoint must be a non-empty URL");
|
|
97
|
+
}
|
|
98
|
+
if (typeof opts.serviceName !== "string" || opts.serviceName.length === 0) {
|
|
99
|
+
throw new OtelExportError("otel-export/bad-service-name",
|
|
100
|
+
"create: serviceName must be a non-empty string");
|
|
101
|
+
}
|
|
102
|
+
var endpoint = opts.endpoint;
|
|
103
|
+
var serviceName = opts.serviceName;
|
|
104
|
+
var headers = opts.headers || {};
|
|
105
|
+
var intervalMs = opts.intervalMs != null ? opts.intervalMs : DEFAULT_INTERVAL_MS;
|
|
106
|
+
if (typeof intervalMs !== "number" || !isFinite(intervalMs) || intervalMs < 0) {
|
|
107
|
+
throw new OtelExportError("otel-export/bad-interval",
|
|
108
|
+
"create: intervalMs must be a non-negative finite number");
|
|
109
|
+
}
|
|
110
|
+
var httpClient = opts.httpClient || require("./http-client");
|
|
111
|
+
var scopeName = (opts.scope && opts.scope.name) || "blamejs";
|
|
112
|
+
var scopeVersion = (opts.scope && opts.scope.version) || "0.5.x";
|
|
113
|
+
var resourceAttrs = Object.assign({ "service.name": serviceName },
|
|
114
|
+
opts.resourceAttributes || {});
|
|
115
|
+
|
|
116
|
+
// Buckets: counters and observations keyed by (name, sorted-attrs).
|
|
117
|
+
var counters = new Map(); // bucketKey → { name, attrs, value, startUnixNano }
|
|
118
|
+
var observations = new Map(); // bucketKey → { name, attrs, sum, count, min, max, startUnixNano }
|
|
119
|
+
var startUnixNano = String(Date.now() * 1e6);
|
|
120
|
+
var timer = null;
|
|
121
|
+
var closed = false;
|
|
122
|
+
|
|
123
|
+
function recordCounter(name, value, attrs) {
|
|
124
|
+
if (closed) return;
|
|
125
|
+
if (typeof name !== "string" || name.length === 0) return;
|
|
126
|
+
var v = typeof value === "number" && isFinite(value) ? value : 1;
|
|
127
|
+
var key = _bucketKey(name, attrs);
|
|
128
|
+
var b = counters.get(key);
|
|
129
|
+
if (!b) {
|
|
130
|
+
b = { name: name, attrs: attrs || {}, value: 0, startUnixNano: startUnixNano };
|
|
131
|
+
counters.set(key, b);
|
|
132
|
+
}
|
|
133
|
+
b.value += v;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function recordObservation(name, value, attrs) {
|
|
137
|
+
if (closed) return;
|
|
138
|
+
if (typeof name !== "string" || name.length === 0) return;
|
|
139
|
+
if (typeof value !== "number" || !isFinite(value)) return;
|
|
140
|
+
var key = _bucketKey(name, attrs);
|
|
141
|
+
var b = observations.get(key);
|
|
142
|
+
if (!b) {
|
|
143
|
+
b = { name: name, attrs: attrs || {}, sum: 0, count: 0, min: value, max: value, startUnixNano: startUnixNano };
|
|
144
|
+
observations.set(key, b);
|
|
145
|
+
}
|
|
146
|
+
b.sum += value;
|
|
147
|
+
b.count += 1;
|
|
148
|
+
if (value < b.min) b.min = value;
|
|
149
|
+
if (value > b.max) b.max = value;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Operators wire this as the observability tap. event(name, value, labels)
|
|
153
|
+
// → recordCounter for value=1 fire-and-forget shapes.
|
|
154
|
+
function tapHandler(name, value, labels) {
|
|
155
|
+
recordCounter(name, value, labels);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
function _drainAndEncode() {
|
|
159
|
+
var nowUnixNano = String(Date.now() * 1e6);
|
|
160
|
+
var metrics = [];
|
|
161
|
+
var c, o;
|
|
162
|
+
|
|
163
|
+
counters.forEach(function (entry) {
|
|
164
|
+
metrics.push({
|
|
165
|
+
name: entry.name,
|
|
166
|
+
sum: {
|
|
167
|
+
dataPoints: [{
|
|
168
|
+
attributes: _attrsToOtlp(entry.attrs),
|
|
169
|
+
startTimeUnixNano: entry.startUnixNano,
|
|
170
|
+
timeUnixNano: nowUnixNano,
|
|
171
|
+
asDouble: entry.value,
|
|
172
|
+
}],
|
|
173
|
+
aggregationTemporality: TEMPORALITY_DELTA,
|
|
174
|
+
isMonotonic: true,
|
|
175
|
+
},
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
void c;
|
|
179
|
+
observations.forEach(function (entry) {
|
|
180
|
+
metrics.push({
|
|
181
|
+
name: entry.name,
|
|
182
|
+
summary: {
|
|
183
|
+
dataPoints: [{
|
|
184
|
+
attributes: _attrsToOtlp(entry.attrs),
|
|
185
|
+
startTimeUnixNano: entry.startUnixNano,
|
|
186
|
+
timeUnixNano: nowUnixNano,
|
|
187
|
+
count: String(entry.count),
|
|
188
|
+
sum: entry.sum,
|
|
189
|
+
quantileValues: [
|
|
190
|
+
{ quantile: 0, value: entry.min },
|
|
191
|
+
{ quantile: 1, value: entry.max },
|
|
192
|
+
],
|
|
193
|
+
}],
|
|
194
|
+
},
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
void o;
|
|
198
|
+
|
|
199
|
+
// Reset buckets (DELTA temporality — each export is the delta).
|
|
200
|
+
counters.clear();
|
|
201
|
+
observations.clear();
|
|
202
|
+
startUnixNano = nowUnixNano;
|
|
203
|
+
if (metrics.length === 0) return null;
|
|
204
|
+
return {
|
|
205
|
+
resourceMetrics: [{
|
|
206
|
+
resource: { attributes: _attrsToOtlp(resourceAttrs) },
|
|
207
|
+
scopeMetrics: [{
|
|
208
|
+
scope: { name: scopeName, version: scopeVersion },
|
|
209
|
+
metrics: metrics,
|
|
210
|
+
}],
|
|
211
|
+
}],
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
async function flush() {
|
|
216
|
+
var payload = _drainAndEncode();
|
|
217
|
+
if (!payload) return { sent: false, reason: "no-data" };
|
|
218
|
+
var body = JSON.stringify(payload);
|
|
219
|
+
try {
|
|
220
|
+
var res = await httpClient.request({
|
|
221
|
+
method: "POST",
|
|
222
|
+
url: endpoint,
|
|
223
|
+
headers: Object.assign({ "Content-Type": "application/json" }, headers),
|
|
224
|
+
body: body,
|
|
225
|
+
});
|
|
226
|
+
if (res.statusCode < 200 || res.statusCode >= 300) {
|
|
227
|
+
throw new OtelExportError("otel-export/upstream-rejected",
|
|
228
|
+
"OTLP endpoint returned " + res.statusCode);
|
|
229
|
+
}
|
|
230
|
+
return { sent: true, statusCode: res.statusCode, bodyLength: body.length };
|
|
231
|
+
} catch (e) {
|
|
232
|
+
if (e && e.isOtelExportError) throw e;
|
|
233
|
+
throw new OtelExportError("otel-export/send-failed",
|
|
234
|
+
"OTLP send failed: " + ((e && e.message) || String(e)));
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function _scheduleFlush() {
|
|
239
|
+
if (intervalMs === 0 || closed) return;
|
|
240
|
+
timer = setTimeout(function () {
|
|
241
|
+
flush().catch(function (_e) { /* never let a flush error crash the timer */ });
|
|
242
|
+
_scheduleFlush();
|
|
243
|
+
}, intervalMs);
|
|
244
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
245
|
+
}
|
|
246
|
+
_scheduleFlush();
|
|
247
|
+
|
|
248
|
+
function close() {
|
|
249
|
+
if (closed) return;
|
|
250
|
+
closed = true;
|
|
251
|
+
if (timer) { clearTimeout(timer); timer = null; }
|
|
252
|
+
return flush().catch(function (_e) { /* close path swallows final-flush errors */ });
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return {
|
|
256
|
+
recordCounter: recordCounter,
|
|
257
|
+
recordObservation: recordObservation,
|
|
258
|
+
tapHandler: tapHandler,
|
|
259
|
+
flush: flush,
|
|
260
|
+
close: close,
|
|
261
|
+
get bufferedCounters() { return counters.size; },
|
|
262
|
+
get bufferedObservations() { return observations.size; },
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
module.exports = {
|
|
267
|
+
create: create,
|
|
268
|
+
OtelExportError: OtelExportError,
|
|
269
|
+
// Test-only encoders for unit-testing the OTLP shape without an HTTP client.
|
|
270
|
+
_attrsToOtlpForTest: _attrsToOtlp,
|
|
271
|
+
_bucketKeyForTest: _bucketKey,
|
|
272
|
+
};
|