@blamejs/core 0.10.11 → 0.10.13
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/README.md +2 -0
- package/index.js +4 -0
- package/lib/cms-codec.js +685 -0
- package/lib/daemon.js +29 -4
- package/lib/mail-crypto-pgp.js +10 -9
- package/lib/mail-crypto-smime.js +15 -31
- package/lib/mail-server-imap.js +8 -0
- package/lib/mail-server-jmap.js +9 -3
- package/lib/mail-server-managesieve.js +4 -0
- package/lib/mail-server-pop3.js +35 -0
- package/lib/mail-server-registry.js +45 -0
- package/lib/mail-server-submission.js +33 -0
- package/lib/metrics.js +68 -8
- package/lib/stream-throttle.js +235 -0
- package/package.json +1 -1
- package/sbom.cdx.json +6 -6
package/lib/daemon.js
CHANGED
|
@@ -237,13 +237,37 @@ function start(opts) {
|
|
|
237
237
|
throw new DaemonError("daemon/already-running",
|
|
238
238
|
"daemon.start: pidFile '" + pidFile + "' held by live PID " + existingLive);
|
|
239
239
|
}
|
|
240
|
-
|
|
240
|
+
// Detached-stdio strategy diverges by platform:
|
|
241
|
+
//
|
|
242
|
+
// POSIX: inherit the parent's open log FD via stdio so the child
|
|
243
|
+
// writes to the operator's log file without re-opening it. POSIX
|
|
244
|
+
// keeps the FD alive across the parent's exit; the child sees it
|
|
245
|
+
// as fd 1 / 2 and writes normally.
|
|
246
|
+
//
|
|
247
|
+
// Windows: passing a parent-opened FD through stdio causes the
|
|
248
|
+
// child to die the moment the parent's handle is closed (the OS
|
|
249
|
+
// ref-counts file handles per-process and the inherited handle
|
|
250
|
+
// becomes invalid on parent exit). The Windows-safe pattern is
|
|
251
|
+
// `stdio: "ignore"` + `windowsHide: true` so the child has no
|
|
252
|
+
// inherited handles to lose, and the operator's child code opens
|
|
253
|
+
// the log file itself once its logger initialises. The child is
|
|
254
|
+
// responsible for `--log` parsing on Windows — pass it via
|
|
255
|
+
// `opts.args` and let the application code handle the open.
|
|
256
|
+
var isWindows = process.platform === "win32";
|
|
257
|
+
var logFd = (!isWindows && logFile) ? _openLogFd(logFile) : null;
|
|
258
|
+
var spawnStdio;
|
|
259
|
+
if (isWindows || logFd === null) {
|
|
260
|
+
spawnStdio = "ignore";
|
|
261
|
+
} else {
|
|
262
|
+
spawnStdio = ["ignore", logFd, logFd];
|
|
263
|
+
}
|
|
241
264
|
var child;
|
|
242
265
|
try {
|
|
243
266
|
child = processSpawn.spawn(opts.command, opts.args || [], {
|
|
244
|
-
detached:
|
|
245
|
-
stdio:
|
|
246
|
-
cwd:
|
|
267
|
+
detached: true,
|
|
268
|
+
stdio: spawnStdio,
|
|
269
|
+
cwd: typeof opts.cwd === "string" ? opts.cwd : undefined,
|
|
270
|
+
windowsHide: isWindows ? true : undefined,
|
|
247
271
|
});
|
|
248
272
|
} catch (e) {
|
|
249
273
|
try { if (typeof logFd === "number") nodeFs.closeSync(logFd); }
|
|
@@ -267,6 +291,7 @@ function start(opts) {
|
|
|
267
291
|
logFile: logFile,
|
|
268
292
|
commandKind: "detached-fork",
|
|
269
293
|
pid: child.pid,
|
|
294
|
+
stdioMode: isWindows ? "ignore-windows" : (logFd === null ? "ignore" : "inherit-logfd"),
|
|
270
295
|
});
|
|
271
296
|
log("daemon started (detached) pid=" + child.pid + " pidFile=" + pidFile);
|
|
272
297
|
return { pid: child.pid, pidFile: pidFile, logFile: logFile, mode: "detached" };
|
package/lib/mail-crypto-pgp.js
CHANGED
|
@@ -60,15 +60,16 @@
|
|
|
60
60
|
* Deferred from v1 (each with the documented condition for opting in):
|
|
61
61
|
* - In-process encrypt + decrypt (Message Encrypted Session Key +
|
|
62
62
|
* Symmetrically Encrypted Integrity Protected Data packets,
|
|
63
|
-
* RFC 9580 §5.1 / §5.13)
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
* `b.
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
63
|
+
* RFC 9580 §5.1 / §5.13) and WKD key discovery (draft-koch-
|
|
64
|
+
* openpgp-webkey-service). Defer condition: ships in v0.10.14
|
|
65
|
+
* alongside `b.mail.crypto.smime` sign + verify — the CMS
|
|
66
|
+
* substrate `b.cms` landed in v0.10.13 unblocked the S/MIME
|
|
67
|
+
* side, and OpenPGP encrypt rides the same release so the
|
|
68
|
+
* mail-crypto surface lights up coherently rather than half-
|
|
69
|
+
* on-each-side across two patches. Cheap escape hatch (pre-
|
|
70
|
+
* v0.10.14): operators wire a third-party OpenPGP library in
|
|
71
|
+
* their own consumer code and call this module's sign() /
|
|
72
|
+
* verify() on the resulting cleartext blob.
|
|
72
73
|
* - v6 signature packets (RFC 9580 §5.2.3, packet version 6 with
|
|
73
74
|
* SHA2-512 fingerprints and salted hashes). Defer condition: v6
|
|
74
75
|
* is not yet emitted by GnuPG 2.4 LTS or by Sequoia stable, so
|
package/lib/mail-crypto-smime.js
CHANGED
|
@@ -64,38 +64,22 @@
|
|
|
64
64
|
* packet decoder shipped in `b.mail.crypto.pgp` — but with
|
|
65
65
|
* dramatically more shape variation across implementations.
|
|
66
66
|
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
*
|
|
70
|
-
*
|
|
71
|
-
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* in-process S/MIME verify (use case + sample message
|
|
76
|
-
* shape).
|
|
77
|
-
* 2. A vendorable ASN.1 BER/DER decoder lands in `lib/vendor/`
|
|
78
|
-
* under the framework's vendoring discipline (MANIFEST.json
|
|
79
|
-
* + sha256 pin + no transitive deps), OR an operator
|
|
80
|
-
* provides a tested decoder we can fold in directly.
|
|
81
|
-
* 3. RFC 8551 §2.5 + RFC 5652 §11 conformance test vectors are
|
|
82
|
-
* available to drive the implementation. (NIST PKITS-style
|
|
83
|
-
* test vectors exist for X.509 chain validation; equivalent
|
|
84
|
-
* coverage for CMS SignedData is sparser.)
|
|
67
|
+
* Reopen condition: the in-tree CMS substrate (`b.cms`) shipped
|
|
68
|
+
* in v0.10.13 — the RFC 5652 SignedData encode + decode + PQC
|
|
69
|
+
* signer dispatch is now available. The S/MIME wire layer
|
|
70
|
+
* (multipart/signed framing, micalg mapping, base64 DER body,
|
|
71
|
+
* Content-Type parameters) lights up on top of `b.cms` in
|
|
72
|
+
* v0.10.14 alongside `b.mail.crypto.pgp` encrypt + decrypt + WKD
|
|
73
|
+
* discovery, so operators get the full mail-crypto surface in a
|
|
74
|
+
* single release rather than half of each side.
|
|
85
75
|
*
|
|
86
|
-
* Cheap escape hatch: operators
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
91
|
-
*
|
|
92
|
-
*
|
|
93
|
-
* any inbound S/MIME-signed message regardless of this module's
|
|
94
|
-
* state.
|
|
95
|
-
*
|
|
96
|
-
* v2 reopen tag: the next minor (v0.9.60+) once the conditions
|
|
97
|
-
* above are met. The deferred surface lights up sign+verify
|
|
98
|
-
* together so operators never see a half-implementation.
|
|
76
|
+
* Cheap escape hatch (pre-v0.10.14): operators wanting in-process
|
|
77
|
+
* S/MIME today compose `b.cms.encodeSignedData` directly with a
|
|
78
|
+
* hand-written multipart/signed wrapper. The MIME framing is two
|
|
79
|
+
* parts (the signed content + `application/pkcs7-signature` body
|
|
80
|
+
* carrying the base64-encoded CMS DER from `b.cms`); the helper
|
|
81
|
+
* in v0.10.14 collapses that into `b.mail.crypto.smime.sign({ ... })`
|
|
82
|
+
* so the next-release path is additive, not a rewrite.
|
|
99
83
|
*
|
|
100
84
|
* RFC citations:
|
|
101
85
|
* - RFC 8551 (S/MIME 4.0 Message Specification, April 2019;
|
package/lib/mail-server-imap.js
CHANGED
|
@@ -582,6 +582,14 @@ function create(opts) {
|
|
|
582
582
|
protocol: "imap",
|
|
583
583
|
defaults: defaults,
|
|
584
584
|
overrides: opts.overrides || {},
|
|
585
|
+
// b.agent.tenant adoption (v0.10.12). Operators wiring multi-
|
|
586
|
+
// tenant IMAP deployments pass `tenantScope` from
|
|
587
|
+
// `b.agent.tenant.create({...})` plus the per-listener tenant id.
|
|
588
|
+
// The registry then gates every dispatch on
|
|
589
|
+
// `tenantScope.check(state.actor, agentTenantId)` before guard
|
|
590
|
+
// validation or audit emission.
|
|
591
|
+
tenantScope: opts.tenantScope || null,
|
|
592
|
+
agentTenantId: opts.agentTenantId || null,
|
|
585
593
|
notFoundHandler: function (verb, _state, socket, parsed) {
|
|
586
594
|
return _writeTagged(socket, parsed.tag,
|
|
587
595
|
"BAD Verb '" + verb + "' not implemented in v1");
|
package/lib/mail-server-jmap.js
CHANGED
|
@@ -220,9 +220,15 @@ function create(opts) {
|
|
|
220
220
|
};
|
|
221
221
|
}
|
|
222
222
|
var registry = mailServerRegistry.create({
|
|
223
|
-
protocol:
|
|
224
|
-
defaults:
|
|
225
|
-
overrides:
|
|
223
|
+
protocol: "jmap",
|
|
224
|
+
defaults: defaults,
|
|
225
|
+
overrides: opts.overrides || {},
|
|
226
|
+
// b.agent.tenant adoption (v0.10.12). When `opts.tenantScope` is
|
|
227
|
+
// supplied, every method dispatch first gates on
|
|
228
|
+
// `tenantScope.check(state.actor, agentTenantId)` — JMAP's
|
|
229
|
+
// accountId scoping continues to apply inside operator handlers.
|
|
230
|
+
tenantScope: opts.tenantScope || null,
|
|
231
|
+
agentTenantId: opts.agentTenantId || null,
|
|
226
232
|
});
|
|
227
233
|
var sessionState = bCrypto.generateToken(16); // allow:raw-byte-literal — opaque session-state token length
|
|
228
234
|
|
|
@@ -446,6 +446,10 @@ function create(opts) {
|
|
|
446
446
|
protocol: "managesieve",
|
|
447
447
|
defaults: defaults,
|
|
448
448
|
overrides: opts.overrides || {},
|
|
449
|
+
// b.agent.tenant adoption (v0.10.12) — see imap factory for the
|
|
450
|
+
// shape.
|
|
451
|
+
tenantScope: opts.tenantScope || null,
|
|
452
|
+
agentTenantId: opts.agentTenantId || null,
|
|
449
453
|
notFoundHandler: function (verb, _state, socket) {
|
|
450
454
|
return _writeNo(socket, "Unknown verb '" + verb + "'");
|
|
451
455
|
},
|
package/lib/mail-server-pop3.js
CHANGED
|
@@ -196,6 +196,38 @@ function create(opts) {
|
|
|
196
196
|
var profile = opts.profile || "strict";
|
|
197
197
|
var authConfig = opts.auth || null;
|
|
198
198
|
var mailStore = opts.mailStore;
|
|
199
|
+
// b.agent.tenant adoption (v0.10.12) — cross-tenant authentication
|
|
200
|
+
// is refused at the AUTH-success boundary BEFORE the listener
|
|
201
|
+
// accepts the actor into transaction state. The scope's `.check`
|
|
202
|
+
// method is validated at create() time so a malformed scope object
|
|
203
|
+
// surfaces as a configuration error rather than rejecting every
|
|
204
|
+
// otherwise-valid auth as "cross-tenant".
|
|
205
|
+
var tenantScope = opts.tenantScope || null;
|
|
206
|
+
var agentTenantId = opts.agentTenantId || null;
|
|
207
|
+
if (tenantScope && typeof tenantScope.check !== "function") {
|
|
208
|
+
throw new MailServerPop3Error("mail-server-pop3/bad-tenant-scope",
|
|
209
|
+
"create: opts.tenantScope must be a b.agent.tenant.create() instance " +
|
|
210
|
+
"(missing .check); a malformed scope would refuse every auth as cross-tenant");
|
|
211
|
+
}
|
|
212
|
+
if (tenantScope && !agentTenantId) {
|
|
213
|
+
throw new MailServerPop3Error("mail-server-pop3/no-agent-tenant-id",
|
|
214
|
+
"create: opts.tenantScope requires opts.agentTenantId");
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function _assertTenantOrRefuse(state, socket, result) {
|
|
218
|
+
if (!tenantScope || !agentTenantId) return true;
|
|
219
|
+
try { tenantScope.check(result.actor, agentTenantId); return true; }
|
|
220
|
+
catch (tenantErr) {
|
|
221
|
+
_emit("mail.server.pop3.cross_tenant_refused",
|
|
222
|
+
{ connectionId: state.id,
|
|
223
|
+
actorTenant: (result.actor && result.actor.tenantId) || null,
|
|
224
|
+
agentTenant: agentTenantId,
|
|
225
|
+
code: (tenantErr && tenantErr.code) || null },
|
|
226
|
+
"denied");
|
|
227
|
+
_writeErr(socket, "Authentication rejected (cross-tenant)");
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
199
231
|
|
|
200
232
|
var rateLimit;
|
|
201
233
|
if (opts.rateLimit === false) {
|
|
@@ -467,6 +499,7 @@ function create(opts) {
|
|
|
467
499
|
})
|
|
468
500
|
.then(function (result) {
|
|
469
501
|
if (result && result.ok && result.actor) {
|
|
502
|
+
if (!_assertTenantOrRefuse(state, socket, result)) return;
|
|
470
503
|
state.actor = result.actor;
|
|
471
504
|
_enterTransaction(state, socket, "PASS");
|
|
472
505
|
return;
|
|
@@ -527,6 +560,7 @@ function create(opts) {
|
|
|
527
560
|
})
|
|
528
561
|
.then(function (result) {
|
|
529
562
|
if (result && result.ok && result.actor) {
|
|
563
|
+
if (!_assertTenantOrRefuse(state, socket, result)) return;
|
|
530
564
|
state.actor = result.actor;
|
|
531
565
|
_enterTransaction(state, socket, "APOP");
|
|
532
566
|
return;
|
|
@@ -595,6 +629,7 @@ function create(opts) {
|
|
|
595
629
|
})
|
|
596
630
|
.then(function (result) {
|
|
597
631
|
if (result && result.ok && result.actor) {
|
|
632
|
+
if (!_assertTenantOrRefuse(state, socket, result)) return;
|
|
598
633
|
state.actor = result.actor;
|
|
599
634
|
_enterTransaction(state, socket, "AUTH/" + mech);
|
|
600
635
|
return;
|
|
@@ -131,6 +131,27 @@ function create(opts) {
|
|
|
131
131
|
throw new MailServerRegistryError("mail-server-registry/unknown-protocol",
|
|
132
132
|
"create: protocol must be 'imap', 'jmap', or 'managesieve' (got '" + opts.protocol + "')");
|
|
133
133
|
}
|
|
134
|
+
// Tenant scope (v0.10.12 — b.agent.tenant adoption).
|
|
135
|
+
// When `opts.tenantScope` is supplied alongside `opts.agentTenantId`,
|
|
136
|
+
// every dispatch first gates on `tenantScope.check(actor,
|
|
137
|
+
// agentTenantId)`. Actor without matching tenantId surfaces as a
|
|
138
|
+
// typed `agent-tenant/cross-tenant-access-refused` per the v0.9.25
|
|
139
|
+
// contract — the listener's catch path converts that into the
|
|
140
|
+
// protocol's `BAD AUTH` / `NO not authorized` reply.
|
|
141
|
+
//
|
|
142
|
+
// Optional: when omitted, dispatch behaves identically to v0.10.11
|
|
143
|
+
// (no per-tenant gate; operators that don't run multi-tenant don't
|
|
144
|
+
// pay the check cost).
|
|
145
|
+
var tenantScope = opts.tenantScope || null;
|
|
146
|
+
var agentTenantId = opts.agentTenantId || null;
|
|
147
|
+
if (tenantScope && typeof tenantScope.check !== "function") {
|
|
148
|
+
throw new MailServerRegistryError("mail-server-registry/bad-tenant-scope",
|
|
149
|
+
"create: opts.tenantScope must be a b.agent.tenant.create() instance (missing .check)");
|
|
150
|
+
}
|
|
151
|
+
if (tenantScope && !agentTenantId) {
|
|
152
|
+
throw new MailServerRegistryError("mail-server-registry/no-agent-tenant-id",
|
|
153
|
+
"create: opts.tenantScope requires opts.agentTenantId (the tenant this listener serves)");
|
|
154
|
+
}
|
|
134
155
|
var catalogue = CATALOGUE[opts.protocol];
|
|
135
156
|
var entries = Object.create(null);
|
|
136
157
|
|
|
@@ -251,6 +272,30 @@ function create(opts) {
|
|
|
251
272
|
*/
|
|
252
273
|
function dispatch(name) {
|
|
253
274
|
var argsArr = Array.prototype.slice.call(arguments, 1);
|
|
275
|
+
// Tenant scope check — pre-dispatch, pre-guard, pre-audit.
|
|
276
|
+
//
|
|
277
|
+
// Two argument shapes occur across the three listeners:
|
|
278
|
+
// - IMAP / ManageSieve dispatch with `(state, socket, parsed)` —
|
|
279
|
+
// state.actor is the actor.
|
|
280
|
+
// - JMAP dispatches with `(actor, resolvedArgs, ctx)` — the
|
|
281
|
+
// first argument IS the actor object directly.
|
|
282
|
+
//
|
|
283
|
+
// Detect both shapes: if argsArr[0].actor exists, use it; else if
|
|
284
|
+
// argsArr[0] itself carries a `tenantId` field, treat it as the
|
|
285
|
+
// actor. The dispatch shapes are documented at the listener
|
|
286
|
+
// factory layer; the registry's job here is uniform enforcement.
|
|
287
|
+
if (tenantScope && argsArr.length > 0 && argsArr[0]) {
|
|
288
|
+
var actor = argsArr[0].actor ||
|
|
289
|
+
(typeof argsArr[0] === "object" && argsArr[0] !== null &&
|
|
290
|
+
Object.prototype.hasOwnProperty.call(argsArr[0], "tenantId")
|
|
291
|
+
? argsArr[0] : null);
|
|
292
|
+
if (actor) {
|
|
293
|
+
// tenantScope.check throws AgentTenantError on cross-tenant;
|
|
294
|
+
// we let the typed error propagate so the listener's
|
|
295
|
+
// catch-path converts it to the protocol's refusal reply.
|
|
296
|
+
tenantScope.check(actor, agentTenantId);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
254
299
|
var entry = entries[name];
|
|
255
300
|
if (!entry) {
|
|
256
301
|
if (typeof opts.notFoundHandler === "function") {
|
|
@@ -266,6 +266,18 @@ function create(opts) {
|
|
|
266
266
|
throw new MailServerSubmissionError("mail-server-submission/no-tls-context",
|
|
267
267
|
"mail.server.submission.create: tlsContext is required");
|
|
268
268
|
}
|
|
269
|
+
// b.agent.tenant shape validation at create() time — a malformed
|
|
270
|
+
// scope object would refuse every auth as cross-tenant, masking the
|
|
271
|
+
// configuration error as an auth outage.
|
|
272
|
+
if (opts.tenantScope && typeof opts.tenantScope.check !== "function") {
|
|
273
|
+
throw new MailServerSubmissionError("mail-server-submission/bad-tenant-scope",
|
|
274
|
+
"create: opts.tenantScope must be a b.agent.tenant.create() instance " +
|
|
275
|
+
"(missing .check); a malformed scope would refuse every auth as cross-tenant");
|
|
276
|
+
}
|
|
277
|
+
if (opts.tenantScope && !opts.agentTenantId) {
|
|
278
|
+
throw new MailServerSubmissionError("mail-server-submission/no-agent-tenant-id",
|
|
279
|
+
"create: opts.tenantScope requires opts.agentTenantId");
|
|
280
|
+
}
|
|
269
281
|
numericBounds.requireAllPositiveFiniteIntIfPresent(opts,
|
|
270
282
|
["maxLineBytes", "maxMessageBytes", "maxRcptsPerMessage", "idleTimeoutMs"],
|
|
271
283
|
"mail.server.submission.", MailServerSubmissionError, "mail-server-submission/bad-bound");
|
|
@@ -742,6 +754,27 @@ function create(opts) {
|
|
|
742
754
|
// successful verify, not whatever state.authPending happens
|
|
743
755
|
// to be at the post-null read (which is always null).
|
|
744
756
|
var successfulMechanism = state.authPending && state.authPending.mechanism;
|
|
757
|
+
// b.agent.tenant gate (v0.10.12). When the listener is
|
|
758
|
+
// wired with `opts.tenantScope` + `opts.agentTenantId`,
|
|
759
|
+
// every authenticated actor must belong to the listener's
|
|
760
|
+
// tenant. Cross-tenant authentication surfaces here as a
|
|
761
|
+
// `535 5.7.0` refusal — the actor never reaches authenticated
|
|
762
|
+
// state, mail submission never begins under the wrong tenant.
|
|
763
|
+
if (opts.tenantScope && opts.agentTenantId) {
|
|
764
|
+
try { opts.tenantScope.check(result.actor, opts.agentTenantId); }
|
|
765
|
+
catch (tenantErr) {
|
|
766
|
+
state.authPending = null;
|
|
767
|
+
_emit("mail.server.submission.cross_tenant_refused",
|
|
768
|
+
{ connectionId: state.id,
|
|
769
|
+
actorTenant: (result.actor && result.actor.tenantId) || null,
|
|
770
|
+
agentTenant: opts.agentTenantId,
|
|
771
|
+
code: (tenantErr && tenantErr.code) || null },
|
|
772
|
+
"denied");
|
|
773
|
+
_writeReply(socket, REPLY_535_AUTH_FAILED,
|
|
774
|
+
"5.7.0 Authentication rejected (cross-tenant)");
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
}
|
|
745
778
|
state.authenticated = true;
|
|
746
779
|
state.actor = result.actor;
|
|
747
780
|
state.authPending = null;
|
package/lib/metrics.js
CHANGED
|
@@ -783,23 +783,66 @@ function _resetForTest() {
|
|
|
783
783
|
* path: string, // absolute path to write the snapshot
|
|
784
784
|
* intervalMs: number, // milliseconds between flushes (>=100)
|
|
785
785
|
* fields: Function, // returns an object — written as JSON
|
|
786
|
+
* registry: object, // optional `b.metrics.create()` handle — adds a
|
|
787
|
+
* // structured `metrics` field carrying every
|
|
788
|
+
* // registered counter / gauge / histogram (incl.
|
|
789
|
+
* // bucket counts) so sidecar readers compose
|
|
790
|
+
* // histogram_quantile() against the snapshot
|
|
786
791
|
* fileMode: number, // POSIX mode (default 0o640 — owner rw, group r)
|
|
787
792
|
*
|
|
788
793
|
* @example
|
|
794
|
+
* var registry = b.metrics.create();
|
|
795
|
+
* var latency = registry.histogram("op_latency_seconds", { buckets: [0.01, 0.1, 1] });
|
|
789
796
|
* var stop = b.metrics.snapshot.startWriter({
|
|
790
797
|
* path: "/run/blamejs/metrics.json",
|
|
791
798
|
* intervalMs: 5000,
|
|
792
|
-
*
|
|
793
|
-
*
|
|
794
|
-
* uptimeMs: process.uptime() * 1000,
|
|
795
|
-
* queueDepth: myQueue.size,
|
|
796
|
-
* lastSyncAt: lastSyncAt,
|
|
797
|
-
* };
|
|
798
|
-
* },
|
|
799
|
+
* registry: registry,
|
|
800
|
+
* fields: function () { return { uptimeMs: process.uptime() * 1000 }; },
|
|
799
801
|
* });
|
|
800
|
-
* //
|
|
802
|
+
* // Snapshot file: { writtenAt, fields, metrics: { op_latency_seconds: { type, buckets, observations: [{ labels, counts, sum, count }] } } }
|
|
801
803
|
* stop();
|
|
802
804
|
*/
|
|
805
|
+
function _serializeRegistry(registry) {
|
|
806
|
+
// Walk every registered metric in the registry.metrics Map and emit
|
|
807
|
+
// a JSON-friendly structured shape. Histograms get full buckets +
|
|
808
|
+
// bucket counts so downstream consumers compose
|
|
809
|
+
// `histogram_quantile()` against the snapshot without a separate
|
|
810
|
+
// exposition endpoint (issue #100).
|
|
811
|
+
var out = {};
|
|
812
|
+
var names = registry.metrics instanceof Map
|
|
813
|
+
? Array.from(registry.metrics.keys()).sort()
|
|
814
|
+
: Object.keys(registry.metrics).sort();
|
|
815
|
+
for (var i = 0; i < names.length; i += 1) {
|
|
816
|
+
var name = names[i];
|
|
817
|
+
var m = registry.metrics instanceof Map ? registry.metrics.get(name) : registry.metrics[name];
|
|
818
|
+
if (!m) continue;
|
|
819
|
+
var entry = { type: m.type, help: m.help || "", labelNames: m.labelNames || [] };
|
|
820
|
+
if (m.type === "histogram") {
|
|
821
|
+
entry.buckets = m.buckets.slice();
|
|
822
|
+
entry.observations = [];
|
|
823
|
+
var hKeys = m.values instanceof Map ? Array.from(m.values.keys()).sort() : Object.keys(m.values).sort();
|
|
824
|
+
for (var hi = 0; hi < hKeys.length; hi += 1) {
|
|
825
|
+
var hv = m.values instanceof Map ? m.values.get(hKeys[hi]) : m.values[hKeys[hi]];
|
|
826
|
+
entry.observations.push({
|
|
827
|
+
labels: hv.labels,
|
|
828
|
+
counts: hv.counts.slice(),
|
|
829
|
+
sum: hv.sum,
|
|
830
|
+
count: hv.count,
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
} else {
|
|
834
|
+
entry.observations = [];
|
|
835
|
+
var vKeys = m.values instanceof Map ? Array.from(m.values.keys()).sort() : Object.keys(m.values).sort();
|
|
836
|
+
for (var vi = 0; vi < vKeys.length; vi += 1) {
|
|
837
|
+
var vv = m.values instanceof Map ? m.values.get(vKeys[vi]) : m.values[vKeys[vi]];
|
|
838
|
+
entry.observations.push({ labels: vv.labels, value: vv.value });
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
out[name] = entry;
|
|
842
|
+
}
|
|
843
|
+
return out;
|
|
844
|
+
}
|
|
845
|
+
|
|
803
846
|
function snapshotStartWriter(opts) {
|
|
804
847
|
opts = opts || {};
|
|
805
848
|
validateOpts.requireNonEmptyString(opts.path,
|
|
@@ -813,8 +856,21 @@ function snapshotStartWriter(opts) {
|
|
|
813
856
|
throw new MetricsError("metrics-snapshot/bad-fields",
|
|
814
857
|
"metrics.snapshot.startWriter: opts.fields must be a function returning the snapshot object");
|
|
815
858
|
}
|
|
859
|
+
// Issue #100 — optional `registry` handle pulls every registered
|
|
860
|
+
// metric into a structured `metrics` field in the JSON snapshot:
|
|
861
|
+
// counters / gauges as `{ value }` per label set, histograms as
|
|
862
|
+
// `{ buckets, observations }` with bucket counts + sum + count.
|
|
863
|
+
// Sidecar readers compose `histogram_quantile()` against the
|
|
864
|
+
// snapshot file without running a separate /metrics endpoint.
|
|
865
|
+
if (opts.registry !== undefined && opts.registry !== null &&
|
|
866
|
+
(typeof opts.registry !== "object" || typeof opts.registry.metrics !== "object")) {
|
|
867
|
+
throw new MetricsError("metrics-snapshot/bad-registry",
|
|
868
|
+
"metrics.snapshot.startWriter: opts.registry must be a metrics registry " +
|
|
869
|
+
"(from b.metrics.create()) or omitted");
|
|
870
|
+
}
|
|
816
871
|
var p = opts.path;
|
|
817
872
|
var fieldsFn = opts.fields;
|
|
873
|
+
var registry = opts.registry || null;
|
|
818
874
|
var intervalMs = opts.intervalMs;
|
|
819
875
|
// CRYPTO-6 — file mode for the atomic write. Default 0o640
|
|
820
876
|
// (owner rw, group r, world none). Operators with a sidecar
|
|
@@ -844,6 +900,10 @@ function snapshotStartWriter(opts) {
|
|
|
844
900
|
writtenAt: new Date().toISOString(),
|
|
845
901
|
fields: snap,
|
|
846
902
|
};
|
|
903
|
+
if (registry) {
|
|
904
|
+
try { payload.metrics = _serializeRegistry(registry); }
|
|
905
|
+
catch (e2) { log("snapshot.metrics serialize failed: " + ((e2 && e2.message) || String(e2))); }
|
|
906
|
+
}
|
|
847
907
|
try {
|
|
848
908
|
// CRYPTO-6 — default 0o640 (owner rw, group r, world none) so
|
|
849
909
|
// operator-supplied snapshot fields aren't world-readable on a
|