midline-agent 0.2.0 → 0.3.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/README.md +29 -1
- package/dist/agent.d.ts +14 -1
- package/dist/agent.js +106 -20
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -0
- package/dist/console.d.ts +46 -0
- package/dist/console.js +167 -0
- package/dist/types.d.ts +9 -2
- package/package.json +1 -1
- package/src/agent.ts +111 -15
- package/src/config.ts +2 -0
- package/src/console.ts +182 -0
- package/src/types.ts +9 -2
- package/test/console.test.js +182 -0
package/README.md
CHANGED
|
@@ -246,6 +246,7 @@ MidlineAgent.init({
|
|
|
246
246
|
},
|
|
247
247
|
redactFields?: string[], // added to the built-in list (maskFields still works)
|
|
248
248
|
redactHeaders?: string[],
|
|
249
|
+
captureConsole?: boolean, // MIDLINE_CAPTURE_CONSOLE — also send what the process prints (off by default)
|
|
249
250
|
|
|
250
251
|
// Delivery
|
|
251
252
|
flushIntervalMs?: number, // default 1500
|
|
@@ -266,6 +267,7 @@ MidlineAgent.init({
|
|
|
266
267
|
```env
|
|
267
268
|
MIDLINE_API_KEY=...
|
|
268
269
|
MIDLINE_ENDPOINT=https://api.usemidline.com
|
|
270
|
+
# MIDLINE_CAPTURE_CONSOLE=true
|
|
269
271
|
|
|
270
272
|
# proxy mode
|
|
271
273
|
TARGET_API_URL=http://localhost:4000
|
|
@@ -278,6 +280,32 @@ TARGET_API_URL=http://localhost:4000
|
|
|
278
280
|
|
|
279
281
|
---
|
|
280
282
|
|
|
283
|
+
## Console output
|
|
284
|
+
|
|
285
|
+
Set `captureConsole: true` (or `MIDLINE_CAPTURE_CONSOLE=true`) and whatever the process prints
|
|
286
|
+
shows up on the dashboard's Logs page as `console` events, one per line, next to your requests. That
|
|
287
|
+
covers `console.log`, Nest's logger, pino, winston: anything written to stdout or stderr.
|
|
288
|
+
|
|
289
|
+
```ts
|
|
290
|
+
MidlineAgent.init({ apiKey: process.env.MIDLINE_API_KEY, captureConsole: true });
|
|
291
|
+
const app = await NestFactory.create(AppModule); // startup lines are captured from here on
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
- Initialise the agent before creating the app. Anything printed before `init()` isn't captured.
|
|
295
|
+
- Your terminal output doesn't change. The agent reads each write after it has happened.
|
|
296
|
+
- Colour codes are stripped, each line is capped at 4096 characters, and lines are redacted like any
|
|
297
|
+
other text.
|
|
298
|
+
- Severity comes from the line: `ERROR` or `FATAL` is high, `WARN` is medium, other stderr output is
|
|
299
|
+
medium, and everything else is low.
|
|
300
|
+
- Up to 100 lines a second are sent, with room for a 1,000-line burst such as Nest mapping its routes
|
|
301
|
+
at startup. Lines past that still print but aren't sent.
|
|
302
|
+
- Printed lines don't count towards request totals, error rates or latency.
|
|
303
|
+
- It needs a Midline server that knows the `console` event type. An older server refuses the first
|
|
304
|
+
line; the agent then turns console capture off, says so once, and keeps sending requests and errors.
|
|
305
|
+
- The agent's own diagnostics are never captured.
|
|
306
|
+
|
|
307
|
+
---
|
|
308
|
+
|
|
281
309
|
## Sensitive data
|
|
282
310
|
|
|
283
311
|
Redaction happens **in your process, before an event is queued**. What's masked never reaches a
|
|
@@ -293,7 +321,7 @@ defence.
|
|
|
293
321
|
`privatekey`, `authorization`, `cookie`, `session`, `credential`, `csrf`, `xsrf`, `signature`,
|
|
294
322
|
`creditcard`, `cardnumber`, `cvv`, `cvc`, `ssn`, `socialsecurity`; and the exact keys `auth`,
|
|
295
323
|
`pwd`, `pin`, `otp`, `sid`, `jwt`, `bearer`.
|
|
296
|
-
- **Values inside any text** — error messages, stack traces, routes: bearer/basic credentials,
|
|
324
|
+
- **Values inside any text** — error messages, stack traces, routes, console lines: bearer/basic credentials,
|
|
297
325
|
JWTs, Midline keys (`ak_…`), Stripe and AWS key formats, PEM private keys, `user:pass@` in URLs,
|
|
298
326
|
and `key=value` / `"key": value` pairs whose key is sensitive.
|
|
299
327
|
- Query strings are never part of `route`.
|
package/dist/agent.d.ts
CHANGED
|
@@ -34,7 +34,7 @@ export interface ExchangeMessage {
|
|
|
34
34
|
truncated?: boolean;
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
|
-
* Ships request
|
|
37
|
+
* Ships request, error and console events to the Midline server.
|
|
38
38
|
*
|
|
39
39
|
* The contract with the host application is that the agent is never allowed to
|
|
40
40
|
* affect it: an unreachable, untrusted, slow or misconfigured Midline server costs a
|
|
@@ -65,6 +65,11 @@ export declare class MidlineAgent {
|
|
|
65
65
|
private dropped;
|
|
66
66
|
private maxRequestBytes;
|
|
67
67
|
private batchLimit;
|
|
68
|
+
private consoleCapture;
|
|
69
|
+
private consoleTokens;
|
|
70
|
+
private consoleRefilledAt;
|
|
71
|
+
/** The server refused a console event: it predates them, so no more are sent. */
|
|
72
|
+
private consoleUnsupported;
|
|
68
73
|
constructor(config?: MidlineConfig);
|
|
69
74
|
/** False when the agent is off: no key, bad config, disabled, rejected key, or closed. */
|
|
70
75
|
get active(): boolean;
|
|
@@ -73,6 +78,8 @@ export declare class MidlineAgent {
|
|
|
73
78
|
addEvent(event: MidlineEvent): void;
|
|
74
79
|
/** Records one HTTP exchange. Captured parts are redacted here, before queueing. */
|
|
75
80
|
recordHttp(exchange: HttpExchange): void;
|
|
81
|
+
private recordConsole;
|
|
82
|
+
private takeConsoleToken;
|
|
76
83
|
/** Sends whatever is buffered now, ignoring backoff, and keeps the process alive until done. */
|
|
77
84
|
flush(): Promise<void>;
|
|
78
85
|
/** Flushes with a deadline, then closes. For graceful shutdown. */
|
|
@@ -98,6 +105,12 @@ export declare class MidlineAgent {
|
|
|
98
105
|
/** Returns false if delivery should stop for this drain. */
|
|
99
106
|
private sendIndividually;
|
|
100
107
|
private send;
|
|
108
|
+
/**
|
|
109
|
+
* True when a rejected event is a console line the server has no event type for,
|
|
110
|
+
* i.e. the server predates console capture. Capture switches itself off rather
|
|
111
|
+
* than paying a refused request for every line printed from then on.
|
|
112
|
+
*/
|
|
113
|
+
private refusedConsole;
|
|
101
114
|
/** One line per distinct fault, not one per failed event. */
|
|
102
115
|
private report;
|
|
103
116
|
private describe;
|
package/dist/agent.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.MidlineAgent = exports.SDK_VERSION = void 0;
|
|
4
4
|
const config_1 = require("./config");
|
|
5
|
+
const console_1 = require("./console");
|
|
5
6
|
const redact_1 = require("./redact");
|
|
6
7
|
const transport_1 = require("./transport");
|
|
7
8
|
exports.SDK_VERSION = (() => {
|
|
@@ -15,6 +16,10 @@ exports.SDK_VERSION = (() => {
|
|
|
15
16
|
const MAX_REQUEST_BYTES = 512 * 1024;
|
|
16
17
|
const MIN_REQUEST_BYTES = 16 * 1024;
|
|
17
18
|
const MAX_QUEUE_BYTES = 16 * 1024 * 1024;
|
|
19
|
+
/** Console lines: a startup burst (Nest mapping every route) fits, a log storm can't crowd out requests. */
|
|
20
|
+
const CONSOLE_BURST_LINES = 1000;
|
|
21
|
+
const CONSOLE_LINES_PER_SECOND = 100;
|
|
22
|
+
const CONSOLE_SEVERITY = { info: "low", warn: "medium", error: "high" };
|
|
18
23
|
/** Verification failures. Retrying is still right: they clear once the server is fixed. */
|
|
19
24
|
const TLS_ERROR_REASONS = {
|
|
20
25
|
DEPTH_ZERO_SELF_SIGNED_CERT: "presented a self-signed certificate",
|
|
@@ -31,7 +36,7 @@ const TLS_ERROR_REASONS = {
|
|
|
31
36
|
HOSTNAME_MISMATCH: "presented a certificate issued for a different hostname",
|
|
32
37
|
};
|
|
33
38
|
/**
|
|
34
|
-
* Ships request
|
|
39
|
+
* Ships request, error and console events to the Midline server.
|
|
35
40
|
*
|
|
36
41
|
* The contract with the host application is that the agent is never allowed to
|
|
37
42
|
* affect it: an unreachable, untrusted, slow or misconfigured Midline server costs a
|
|
@@ -71,6 +76,11 @@ class MidlineAgent {
|
|
|
71
76
|
this.dropped = 0;
|
|
72
77
|
this.maxRequestBytes = MAX_REQUEST_BYTES;
|
|
73
78
|
this.batchLimit = Number.MAX_SAFE_INTEGER;
|
|
79
|
+
this.consoleCapture = null;
|
|
80
|
+
this.consoleTokens = CONSOLE_BURST_LINES;
|
|
81
|
+
this.consoleRefilledAt = Date.now();
|
|
82
|
+
/** The server refused a console event: it predates them, so no more are sent. */
|
|
83
|
+
this.consoleUnsupported = false;
|
|
74
84
|
this.onErrorHook = config.onError;
|
|
75
85
|
this.debugLogs = config.debug ?? (0, config_1.envFlag)("MIDLINE_DEBUG") ?? false;
|
|
76
86
|
this.redactor = new redact_1.Redactor([...(config.redactFields ?? []), ...(config.maskFields ?? [])], config.redactHeaders);
|
|
@@ -101,10 +111,15 @@ class MidlineAgent {
|
|
|
101
111
|
userAgent: `midline-agent/${exports.SDK_VERSION} node/${process.version}`,
|
|
102
112
|
});
|
|
103
113
|
this.timer = setInterval(() => {
|
|
114
|
+
this.consoleCapture?.flushPending();
|
|
104
115
|
void this.drain(false);
|
|
105
116
|
}, resolved.flushIntervalMs);
|
|
106
117
|
// Telemetry must never be the reason a process refuses to exit.
|
|
107
118
|
this.timer.unref?.();
|
|
119
|
+
if (resolved.captureConsole) {
|
|
120
|
+
this.consoleCapture = new console_1.ConsoleCapture((line) => this.recordConsole(line));
|
|
121
|
+
this.consoleCapture.install();
|
|
122
|
+
}
|
|
108
123
|
}
|
|
109
124
|
/** False when the agent is off: no key, bad config, disabled, rejected key, or closed. */
|
|
110
125
|
get active() {
|
|
@@ -165,10 +180,43 @@ class MidlineAgent {
|
|
|
165
180
|
this.report("event-build", `midline: could not record a request (${err?.message}); skipped it.`);
|
|
166
181
|
}
|
|
167
182
|
}
|
|
183
|
+
recordConsole(line) {
|
|
184
|
+
if (!this.active || this.consoleUnsupported)
|
|
185
|
+
return;
|
|
186
|
+
if (!this.takeConsoleToken()) {
|
|
187
|
+
this.dropped += 1;
|
|
188
|
+
this.report("console-rate", `midline: console output is arriving faster than ${CONSOLE_LINES_PER_SECOND} lines/s; the extra lines still print but are not sent.`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
try {
|
|
192
|
+
this.enqueue(this.toWire({
|
|
193
|
+
type: "console",
|
|
194
|
+
path: line.stream,
|
|
195
|
+
message: line.text,
|
|
196
|
+
timestamp: line.timestamp,
|
|
197
|
+
severity: CONSOLE_SEVERITY[line.level],
|
|
198
|
+
category: "application",
|
|
199
|
+
integration: "console",
|
|
200
|
+
}, false));
|
|
201
|
+
}
|
|
202
|
+
catch (err) {
|
|
203
|
+
this.report("event-build", `midline: could not record a console line (${err?.message}); skipped it.`);
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
takeConsoleToken() {
|
|
207
|
+
const now = Date.now();
|
|
208
|
+
this.consoleTokens = Math.min(CONSOLE_BURST_LINES, this.consoleTokens + ((now - this.consoleRefilledAt) / 1000) * CONSOLE_LINES_PER_SECOND);
|
|
209
|
+
this.consoleRefilledAt = now;
|
|
210
|
+
if (this.consoleTokens < 1)
|
|
211
|
+
return false;
|
|
212
|
+
this.consoleTokens -= 1;
|
|
213
|
+
return true;
|
|
214
|
+
}
|
|
168
215
|
/** Sends whatever is buffered now, ignoring backoff, and keeps the process alive until done. */
|
|
169
216
|
async flush() {
|
|
170
217
|
if (!this.active)
|
|
171
218
|
return;
|
|
219
|
+
this.consoleCapture?.flushPending(true);
|
|
172
220
|
this.retryAfter = 0;
|
|
173
221
|
if (this.drainPromise) {
|
|
174
222
|
await this.drainPromise;
|
|
@@ -197,6 +245,8 @@ class MidlineAgent {
|
|
|
197
245
|
clearInterval(this.timer);
|
|
198
246
|
this.timer = null;
|
|
199
247
|
}
|
|
248
|
+
this.consoleCapture?.uninstall();
|
|
249
|
+
this.consoleCapture = null;
|
|
200
250
|
this.queue = [];
|
|
201
251
|
this.queueBytes = 0;
|
|
202
252
|
this.transport?.destroy();
|
|
@@ -241,13 +291,17 @@ class MidlineAgent {
|
|
|
241
291
|
toWire(event, preRedacted) {
|
|
242
292
|
const config = this.config;
|
|
243
293
|
const type = event.type;
|
|
294
|
+
const isConsole = type === "console";
|
|
244
295
|
const statusCode = Number.isInteger(event.statusCode) && event.statusCode >= 100 && event.statusCode <= 599
|
|
245
296
|
? event.statusCode
|
|
246
297
|
: type === "error" ? 500 : undefined;
|
|
247
298
|
const { severity, category } = classify(event, statusCode);
|
|
248
299
|
const route = this.redactor.string(stripQuery(event.path) || "/", 2048);
|
|
249
300
|
const payload = {};
|
|
250
|
-
if (
|
|
301
|
+
if (isConsole) {
|
|
302
|
+
payload.message = this.redactor.string(event.message ?? "", console_1.MAX_LINE_CHARS);
|
|
303
|
+
}
|
|
304
|
+
else if (type === "error" || event.message) {
|
|
251
305
|
payload.error = event.message ? this.redactor.string(event.message, 1024) : type === "error" ? "Unknown error" : undefined;
|
|
252
306
|
}
|
|
253
307
|
if (event.stack)
|
|
@@ -290,9 +344,10 @@ class MidlineAgent {
|
|
|
290
344
|
apiKey: config.apiKey,
|
|
291
345
|
eventType: type,
|
|
292
346
|
route,
|
|
293
|
-
|
|
347
|
+
// A printed line has no method or timing; defaults here would make it look like a request.
|
|
348
|
+
method: isConsole ? undefined : (event.method || "GET").toUpperCase().slice(0, 16),
|
|
294
349
|
statusCode,
|
|
295
|
-
responseTime: Math.min(Math.max(0, Math.round(Number(event.duration) || 0)), 86400000),
|
|
350
|
+
responseTime: isConsole ? undefined : Math.min(Math.max(0, Math.round(Number(event.duration) || 0)), 86400000),
|
|
296
351
|
timestamp: validTimestamp(event.timestamp),
|
|
297
352
|
// Clamped to the server's validation limits: one over-long field would
|
|
298
353
|
// otherwise get every event rejected.
|
|
@@ -405,6 +460,8 @@ class MidlineAgent {
|
|
|
405
460
|
}
|
|
406
461
|
if (outcome.kind === "rejected") {
|
|
407
462
|
if (batch.length === 1) {
|
|
463
|
+
if (this.refusedConsole(batch[0], outcome.detail))
|
|
464
|
+
continue;
|
|
408
465
|
this.dropped += 1;
|
|
409
466
|
this.report(`rejected:${outcome.detail}`, `midline: the Midline server rejected an event (${outcome.detail}); dropped it.`);
|
|
410
467
|
continue;
|
|
@@ -424,13 +481,17 @@ class MidlineAgent {
|
|
|
424
481
|
/** Returns false if delivery should stop for this drain. */
|
|
425
482
|
async sendIndividually(batch, keepProcessAlive) {
|
|
426
483
|
for (let index = 0; index < batch.length; index++) {
|
|
484
|
+
if (this.consoleUnsupported && batch[index].wire.eventType === "console")
|
|
485
|
+
continue;
|
|
427
486
|
const outcome = await this.send([batch[index]], keepProcessAlive);
|
|
428
487
|
if (outcome.kind === "ok") {
|
|
429
488
|
this.onSuccess();
|
|
430
489
|
}
|
|
431
490
|
else if (outcome.kind === "rejected" || outcome.kind === "tooLarge") {
|
|
432
|
-
this.dropped += 1;
|
|
433
491
|
const detail = outcome.kind === "rejected" ? outcome.detail : "HTTP 413";
|
|
492
|
+
if (outcome.kind === "rejected" && this.refusedConsole(batch[index], detail))
|
|
493
|
+
continue;
|
|
494
|
+
this.dropped += 1;
|
|
434
495
|
this.report(`rejected:${detail}`, `midline: the Midline server rejected an event (${detail}); dropped it.`);
|
|
435
496
|
}
|
|
436
497
|
else if (outcome.kind === "stop") {
|
|
@@ -493,6 +554,24 @@ class MidlineAgent {
|
|
|
493
554
|
}
|
|
494
555
|
return { kind: "rejected", detail: `HTTP ${status}${serverMessage(result.body)}` };
|
|
495
556
|
}
|
|
557
|
+
/**
|
|
558
|
+
* True when a rejected event is a console line the server has no event type for,
|
|
559
|
+
* i.e. the server predates console capture. Capture switches itself off rather
|
|
560
|
+
* than paying a refused request for every line printed from then on.
|
|
561
|
+
*/
|
|
562
|
+
refusedConsole(item, detail) {
|
|
563
|
+
if (item.wire.eventType !== "console" || !detail.includes("eventType"))
|
|
564
|
+
return false;
|
|
565
|
+
if (!this.consoleUnsupported) {
|
|
566
|
+
this.consoleUnsupported = true;
|
|
567
|
+
this.consoleCapture?.uninstall();
|
|
568
|
+
this.consoleCapture = null;
|
|
569
|
+
this.queue = this.queue.filter((queued) => queued.wire.eventType !== "console");
|
|
570
|
+
this.queueBytes = this.queue.reduce((sum, queued) => sum + queued.bytes, 0);
|
|
571
|
+
this.log("warn", "midline: this Midline server does not accept console events yet (it needs updating), so console capture is off. Request and error monitoring carry on.");
|
|
572
|
+
}
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
496
575
|
/** One line per distinct fault, not one per failed event. */
|
|
497
576
|
report(signature, message) {
|
|
498
577
|
if (this.lastNotice === signature && !this.debugLogs) {
|
|
@@ -558,22 +637,26 @@ class MidlineAgent {
|
|
|
558
637
|
this.close();
|
|
559
638
|
}
|
|
560
639
|
log(level, message) {
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
640
|
+
// The agent's own diagnostics are never the application's console output,
|
|
641
|
+
// including when a hook hands them to a logger that prints them.
|
|
642
|
+
(0, console_1.withoutConsoleCapture)(() => {
|
|
643
|
+
if (this.onErrorHook) {
|
|
644
|
+
try {
|
|
645
|
+
this.onErrorHook(message);
|
|
646
|
+
}
|
|
647
|
+
catch {
|
|
648
|
+
// A broken logging hook must not become the host application's problem.
|
|
649
|
+
}
|
|
650
|
+
if (!this.debugLogs)
|
|
651
|
+
return;
|
|
567
652
|
}
|
|
568
|
-
if (
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
else
|
|
576
|
-
console.info(message);
|
|
653
|
+
if (level === "error")
|
|
654
|
+
console.error(message);
|
|
655
|
+
else if (level === "warn")
|
|
656
|
+
console.warn(message);
|
|
657
|
+
else
|
|
658
|
+
console.info(message);
|
|
659
|
+
});
|
|
577
660
|
}
|
|
578
661
|
}
|
|
579
662
|
exports.MidlineAgent = MidlineAgent;
|
|
@@ -595,6 +678,9 @@ function classify(event, statusCode) {
|
|
|
595
678
|
else if (event.type === "custom") {
|
|
596
679
|
category = "business";
|
|
597
680
|
}
|
|
681
|
+
else if (event.type === "console") {
|
|
682
|
+
category = "application";
|
|
683
|
+
}
|
|
598
684
|
else if (statusCode && statusCode >= 500) {
|
|
599
685
|
severity = "high";
|
|
600
686
|
category = "application";
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -216,6 +216,7 @@ function resolveConfig(config) {
|
|
|
216
216
|
redactFields: [...(config.redactFields ?? []), ...(config.maskFields ?? [])],
|
|
217
217
|
redactHeaders: config.redactHeaders ?? [],
|
|
218
218
|
capture: resolveCapture(config.capture),
|
|
219
|
+
captureConsole: config.captureConsole ?? envFlag("MIDLINE_CAPTURE_CONSOLE") ?? false,
|
|
219
220
|
onError: config.onError,
|
|
220
221
|
debug: config.debug ?? envFlag("MIDLINE_DEBUG") ?? false,
|
|
221
222
|
flushIntervalMs: positiveInt(config.flushIntervalMs, 1500, 50, 60000),
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console capture: what the host process prints — `console.log`, Nest's logger,
|
|
3
|
+
* pino, anything that reaches stdout or stderr — copied into Midline line by line.
|
|
4
|
+
*
|
|
5
|
+
* The streams are never changed. The original `write` runs first with the
|
|
6
|
+
* caller's own arguments and its return value is handed straight back, so
|
|
7
|
+
* back-pressure, callbacks and the terminal output are exactly what they were.
|
|
8
|
+
*/
|
|
9
|
+
export type ConsoleStream = "stdout" | "stderr";
|
|
10
|
+
export type ConsoleLevel = "info" | "warn" | "error";
|
|
11
|
+
export interface ConsoleLine {
|
|
12
|
+
stream: ConsoleStream;
|
|
13
|
+
text: string;
|
|
14
|
+
level: ConsoleLevel;
|
|
15
|
+
/** When the line's first chunk was written, not when it was sent. */
|
|
16
|
+
timestamp: string;
|
|
17
|
+
}
|
|
18
|
+
/** Longest line kept. A log line past this is almost always a dumped payload. */
|
|
19
|
+
export declare const MAX_LINE_CHARS = 4096;
|
|
20
|
+
/**
|
|
21
|
+
* Runs `fn` with capture paused. The agent writes its own diagnostics through
|
|
22
|
+
* this, so a warning about delivery can't become an event that needs delivering.
|
|
23
|
+
*/
|
|
24
|
+
export declare function withoutConsoleCapture<T>(fn: () => T): T;
|
|
25
|
+
export declare class ConsoleCapture {
|
|
26
|
+
private readonly onLine;
|
|
27
|
+
private readonly originals;
|
|
28
|
+
private readonly wrappers;
|
|
29
|
+
private readonly pending;
|
|
30
|
+
private busy;
|
|
31
|
+
private stopped;
|
|
32
|
+
constructor(onLine: (line: ConsoleLine) => void);
|
|
33
|
+
install(): void;
|
|
34
|
+
/** Restores the streams. A wrapper installed on top of ours is left alone — ours just goes quiet underneath it. */
|
|
35
|
+
uninstall(): void;
|
|
36
|
+
/**
|
|
37
|
+
* Emits lines still waiting for a newline. Without `force`, only the ones that
|
|
38
|
+
* were already unfinished at the previous call, so a line being written in
|
|
39
|
+
* pieces isn't cut in half by a timer tick.
|
|
40
|
+
*/
|
|
41
|
+
flushPending(force?: boolean): void;
|
|
42
|
+
private take;
|
|
43
|
+
private emit;
|
|
44
|
+
}
|
|
45
|
+
/** Nest, most JSON loggers and console.warn/error leave enough behind to tell a failure from chatter. */
|
|
46
|
+
export declare function levelOf(stream: ConsoleStream, text: string): ConsoleLevel;
|
package/dist/console.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Console capture: what the host process prints — `console.log`, Nest's logger,
|
|
4
|
+
* pino, anything that reaches stdout or stderr — copied into Midline line by line.
|
|
5
|
+
*
|
|
6
|
+
* The streams are never changed. The original `write` runs first with the
|
|
7
|
+
* caller's own arguments and its return value is handed straight back, so
|
|
8
|
+
* back-pressure, callbacks and the terminal output are exactly what they were.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.ConsoleCapture = exports.MAX_LINE_CHARS = void 0;
|
|
12
|
+
exports.withoutConsoleCapture = withoutConsoleCapture;
|
|
13
|
+
exports.levelOf = levelOf;
|
|
14
|
+
/** Longest line kept. A log line past this is almost always a dumped payload. */
|
|
15
|
+
exports.MAX_LINE_CHARS = 4096;
|
|
16
|
+
const STREAMS = ["stdout", "stderr"];
|
|
17
|
+
/** CSI (colours, cursor moves), OSC (titles, hyperlinks) and the remaining two-byte escapes. */
|
|
18
|
+
const ANSI_ESCAPE = /\x1B\[[0-?]*[ -\/]*[@-~]|\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)|\x1B[@-Z\\-_]/g;
|
|
19
|
+
// Level words are matched in capitals only: lower-case "error" turns up in plenty of harmless lines.
|
|
20
|
+
const ERROR_MARKERS = [/\b(?:ERROR|FATAL|CRITICAL)\b/, /"level"\s*:\s*"?(?:error|fatal|critical|50|60)\b/i];
|
|
21
|
+
const WARN_MARKERS = [/\bWARN(?:ING)?\b/, /"level"\s*:\s*"?(?:warn|warning|40)\b/i];
|
|
22
|
+
let suppressed = 0;
|
|
23
|
+
/**
|
|
24
|
+
* Runs `fn` with capture paused. The agent writes its own diagnostics through
|
|
25
|
+
* this, so a warning about delivery can't become an event that needs delivering.
|
|
26
|
+
*/
|
|
27
|
+
function withoutConsoleCapture(fn) {
|
|
28
|
+
suppressed += 1;
|
|
29
|
+
try {
|
|
30
|
+
return fn();
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
suppressed -= 1;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
class ConsoleCapture {
|
|
37
|
+
constructor(onLine) {
|
|
38
|
+
this.onLine = onLine;
|
|
39
|
+
this.originals = new Map();
|
|
40
|
+
this.wrappers = new Map();
|
|
41
|
+
this.pending = {
|
|
42
|
+
stdout: { text: "", at: "", stale: false },
|
|
43
|
+
stderr: { text: "", at: "", stale: false },
|
|
44
|
+
};
|
|
45
|
+
this.busy = false;
|
|
46
|
+
this.stopped = false;
|
|
47
|
+
}
|
|
48
|
+
install() {
|
|
49
|
+
for (const stream of STREAMS) {
|
|
50
|
+
const target = process[stream];
|
|
51
|
+
const original = target.write;
|
|
52
|
+
const capture = this;
|
|
53
|
+
const wrapper = function (...args) {
|
|
54
|
+
const result = original.apply(this, args);
|
|
55
|
+
capture.take(stream, args[0], args[1]);
|
|
56
|
+
return result;
|
|
57
|
+
};
|
|
58
|
+
this.originals.set(stream, original);
|
|
59
|
+
this.wrappers.set(stream, wrapper);
|
|
60
|
+
target.write = wrapper;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Restores the streams. A wrapper installed on top of ours is left alone — ours just goes quiet underneath it. */
|
|
64
|
+
uninstall() {
|
|
65
|
+
this.stopped = true;
|
|
66
|
+
for (const stream of STREAMS) {
|
|
67
|
+
const wrapper = this.wrappers.get(stream);
|
|
68
|
+
if (wrapper && process[stream].write === wrapper) {
|
|
69
|
+
process[stream].write = this.originals.get(stream);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
this.wrappers.clear();
|
|
73
|
+
this.originals.clear();
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Emits lines still waiting for a newline. Without `force`, only the ones that
|
|
77
|
+
* were already unfinished at the previous call, so a line being written in
|
|
78
|
+
* pieces isn't cut in half by a timer tick.
|
|
79
|
+
*/
|
|
80
|
+
flushPending(force = false) {
|
|
81
|
+
if (this.stopped || this.busy)
|
|
82
|
+
return;
|
|
83
|
+
this.busy = true;
|
|
84
|
+
try {
|
|
85
|
+
for (const stream of STREAMS) {
|
|
86
|
+
const pending = this.pending[stream];
|
|
87
|
+
if (!pending.text)
|
|
88
|
+
continue;
|
|
89
|
+
if (force || pending.stale) {
|
|
90
|
+
this.pending[stream] = { text: "", at: "", stale: false };
|
|
91
|
+
this.emit(stream, pending.text, pending.at);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
pending.stale = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Best-effort, like the rest of capture.
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
this.busy = false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
take(stream, chunk, encoding) {
|
|
106
|
+
// `busy` also stops a loop if whatever handles a line prints something itself.
|
|
107
|
+
if (this.stopped || this.busy || suppressed > 0)
|
|
108
|
+
return;
|
|
109
|
+
this.busy = true;
|
|
110
|
+
try {
|
|
111
|
+
const text = decode(chunk, encoding);
|
|
112
|
+
if (!text)
|
|
113
|
+
return;
|
|
114
|
+
const now = new Date().toISOString();
|
|
115
|
+
const pending = this.pending[stream];
|
|
116
|
+
const lines = (pending.text + text).split("\n");
|
|
117
|
+
let rest = lines.pop() ?? "";
|
|
118
|
+
let at = pending.text ? pending.at : now;
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
this.emit(stream, line, at);
|
|
121
|
+
at = now;
|
|
122
|
+
}
|
|
123
|
+
if (rest.length > exports.MAX_LINE_CHARS) {
|
|
124
|
+
this.emit(stream, rest, at);
|
|
125
|
+
rest = "";
|
|
126
|
+
}
|
|
127
|
+
this.pending[stream] = { text: rest, at: rest ? at : "", stale: false };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// The write itself already happened; a line we couldn't read is just not sent.
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
this.busy = false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
emit(stream, raw, at) {
|
|
137
|
+
let text = raw.replace(ANSI_ESCAPE, "").replace(/\r$/, "");
|
|
138
|
+
// A carriage return redraws the line; what's left after the last one is what the terminal shows.
|
|
139
|
+
text = text.slice(text.lastIndexOf("\r") + 1);
|
|
140
|
+
if (!text.trim())
|
|
141
|
+
return;
|
|
142
|
+
if (text.length > exports.MAX_LINE_CHARS)
|
|
143
|
+
text = text.slice(0, exports.MAX_LINE_CHARS);
|
|
144
|
+
this.onLine({ stream, text, level: levelOf(stream, text), timestamp: at });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
exports.ConsoleCapture = ConsoleCapture;
|
|
148
|
+
/** Nest, most JSON loggers and console.warn/error leave enough behind to tell a failure from chatter. */
|
|
149
|
+
function levelOf(stream, text) {
|
|
150
|
+
const head = text.slice(0, 256);
|
|
151
|
+
if (ERROR_MARKERS.some((pattern) => pattern.test(head)))
|
|
152
|
+
return "error";
|
|
153
|
+
if (WARN_MARKERS.some((pattern) => pattern.test(head)))
|
|
154
|
+
return "warn";
|
|
155
|
+
return stream === "stderr" ? "warn" : "info";
|
|
156
|
+
}
|
|
157
|
+
function decode(chunk, encoding) {
|
|
158
|
+
if (typeof chunk === "string") {
|
|
159
|
+
return typeof encoding === "string" && !/^utf-?8$/i.test(encoding) && Buffer.isEncoding(encoding)
|
|
160
|
+
? Buffer.from(chunk, encoding).toString("utf8")
|
|
161
|
+
: chunk;
|
|
162
|
+
}
|
|
163
|
+
if (chunk instanceof Uint8Array) {
|
|
164
|
+
return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength).toString("utf8");
|
|
165
|
+
}
|
|
166
|
+
return "";
|
|
167
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
/** A PEM string, PEM bytes, a path to a PEM file, or several of those. */
|
|
2
2
|
export type CaInput = string | Buffer | Array<string | Buffer>;
|
|
3
|
-
export type EventType = "request" | "error" | "security" | "performance" | "custom";
|
|
3
|
+
export type EventType = "request" | "error" | "security" | "performance" | "custom" | "console";
|
|
4
4
|
export type EventSeverity = "low" | "medium" | "high" | "critical";
|
|
5
5
|
export type EventCategory = "application" | "infrastructure" | "security" | "performance" | "business";
|
|
6
6
|
/**
|
|
@@ -55,6 +55,13 @@ export interface MidlineConfig {
|
|
|
55
55
|
redactHeaders?: string[];
|
|
56
56
|
/** What to capture beyond method/path/status/timing. Defaults to nothing. */
|
|
57
57
|
capture?: CaptureOptions;
|
|
58
|
+
/**
|
|
59
|
+
* Also send what the process prints — `console.log`, Nest's logger, anything
|
|
60
|
+
* written to stdout or stderr — as `console` events, one per line, redacted like
|
|
61
|
+
* any other text. Initialise the agent before creating the app to include its
|
|
62
|
+
* startup lines. Off by default. Env fallback: `MIDLINE_CAPTURE_CONSOLE`.
|
|
63
|
+
*/
|
|
64
|
+
captureConsole?: boolean;
|
|
58
65
|
/** Set to false to keep the agent inert. Env fallback: `MIDLINE_ENABLED`. */
|
|
59
66
|
enabled?: boolean;
|
|
60
67
|
/**
|
|
@@ -128,5 +135,5 @@ export interface MidlineEvent {
|
|
|
128
135
|
errorCode?: string;
|
|
129
136
|
/** Set when the client disconnected before the response finished. */
|
|
130
137
|
aborted?: boolean;
|
|
131
|
-
integration?: "express" | "node-http" | "proxy" | "manual";
|
|
138
|
+
integration?: "express" | "node-http" | "proxy" | "console" | "manual";
|
|
132
139
|
}
|
package/package.json
CHANGED
package/src/agent.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { ConfigError, ResolvedCapture, ResolvedConfig, envFlag, resolveConfig } from "./config";
|
|
2
|
+
import { ConsoleCapture, ConsoleLevel, ConsoleLine, MAX_LINE_CHARS, withoutConsoleCapture } from "./console";
|
|
2
3
|
import type { RequestContext } from "./context";
|
|
3
4
|
import { Redactor } from "./redact";
|
|
4
5
|
import { Transport } from "./transport";
|
|
@@ -16,6 +17,11 @@ const MAX_REQUEST_BYTES = 512 * 1024;
|
|
|
16
17
|
const MIN_REQUEST_BYTES = 16 * 1024;
|
|
17
18
|
const MAX_QUEUE_BYTES = 16 * 1024 * 1024;
|
|
18
19
|
|
|
20
|
+
/** Console lines: a startup burst (Nest mapping every route) fits, a log storm can't crowd out requests. */
|
|
21
|
+
const CONSOLE_BURST_LINES = 1000;
|
|
22
|
+
const CONSOLE_LINES_PER_SECOND = 100;
|
|
23
|
+
const CONSOLE_SEVERITY: Record<ConsoleLevel, EventSeverity> = { info: "low", warn: "medium", error: "high" };
|
|
24
|
+
|
|
19
25
|
/** Verification failures. Retrying is still right: they clear once the server is fixed. */
|
|
20
26
|
const TLS_ERROR_REASONS: Record<string, string> = {
|
|
21
27
|
DEPTH_ZERO_SELF_SIGNED_CERT: "presented a self-signed certificate",
|
|
@@ -76,7 +82,7 @@ export interface ExchangeMessage {
|
|
|
76
82
|
}
|
|
77
83
|
|
|
78
84
|
/**
|
|
79
|
-
* Ships request
|
|
85
|
+
* Ships request, error and console events to the Midline server.
|
|
80
86
|
*
|
|
81
87
|
* The contract with the host application is that the agent is never allowed to
|
|
82
88
|
* affect it: an unreachable, untrusted, slow or misconfigured Midline server costs a
|
|
@@ -126,6 +132,11 @@ export class MidlineAgent {
|
|
|
126
132
|
private dropped = 0;
|
|
127
133
|
private maxRequestBytes = MAX_REQUEST_BYTES;
|
|
128
134
|
private batchLimit = Number.MAX_SAFE_INTEGER;
|
|
135
|
+
private consoleCapture: ConsoleCapture | null = null;
|
|
136
|
+
private consoleTokens = CONSOLE_BURST_LINES;
|
|
137
|
+
private consoleRefilledAt = Date.now();
|
|
138
|
+
/** The server refused a console event: it predates them, so no more are sent. */
|
|
139
|
+
private consoleUnsupported = false;
|
|
129
140
|
|
|
130
141
|
constructor(config: MidlineConfig = {}) {
|
|
131
142
|
this.onErrorHook = config.onError;
|
|
@@ -162,10 +173,16 @@ export class MidlineAgent {
|
|
|
162
173
|
});
|
|
163
174
|
|
|
164
175
|
this.timer = setInterval(() => {
|
|
176
|
+
this.consoleCapture?.flushPending();
|
|
165
177
|
void this.drain(false);
|
|
166
178
|
}, resolved.flushIntervalMs);
|
|
167
179
|
// Telemetry must never be the reason a process refuses to exit.
|
|
168
180
|
this.timer.unref?.();
|
|
181
|
+
|
|
182
|
+
if (resolved.captureConsole) {
|
|
183
|
+
this.consoleCapture = new ConsoleCapture((line) => this.recordConsole(line));
|
|
184
|
+
this.consoleCapture.install();
|
|
185
|
+
}
|
|
169
186
|
}
|
|
170
187
|
|
|
171
188
|
/** False when the agent is off: no key, bad config, disabled, rejected key, or closed. */
|
|
@@ -229,9 +246,52 @@ export class MidlineAgent {
|
|
|
229
246
|
}
|
|
230
247
|
}
|
|
231
248
|
|
|
249
|
+
private recordConsole(line: ConsoleLine): void {
|
|
250
|
+
if (!this.active || this.consoleUnsupported) return;
|
|
251
|
+
if (!this.takeConsoleToken()) {
|
|
252
|
+
this.dropped += 1;
|
|
253
|
+
this.report(
|
|
254
|
+
"console-rate",
|
|
255
|
+
`midline: console output is arriving faster than ${CONSOLE_LINES_PER_SECOND} lines/s; the extra lines still print but are not sent.`,
|
|
256
|
+
);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
259
|
+
try {
|
|
260
|
+
this.enqueue(
|
|
261
|
+
this.toWire(
|
|
262
|
+
{
|
|
263
|
+
type: "console",
|
|
264
|
+
path: line.stream,
|
|
265
|
+
message: line.text,
|
|
266
|
+
timestamp: line.timestamp,
|
|
267
|
+
severity: CONSOLE_SEVERITY[line.level],
|
|
268
|
+
category: "application",
|
|
269
|
+
integration: "console",
|
|
270
|
+
},
|
|
271
|
+
false,
|
|
272
|
+
),
|
|
273
|
+
);
|
|
274
|
+
} catch (err) {
|
|
275
|
+
this.report("event-build", `midline: could not record a console line (${(err as Error)?.message}); skipped it.`);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private takeConsoleToken(): boolean {
|
|
280
|
+
const now = Date.now();
|
|
281
|
+
this.consoleTokens = Math.min(
|
|
282
|
+
CONSOLE_BURST_LINES,
|
|
283
|
+
this.consoleTokens + ((now - this.consoleRefilledAt) / 1000) * CONSOLE_LINES_PER_SECOND,
|
|
284
|
+
);
|
|
285
|
+
this.consoleRefilledAt = now;
|
|
286
|
+
if (this.consoleTokens < 1) return false;
|
|
287
|
+
this.consoleTokens -= 1;
|
|
288
|
+
return true;
|
|
289
|
+
}
|
|
290
|
+
|
|
232
291
|
/** Sends whatever is buffered now, ignoring backoff, and keeps the process alive until done. */
|
|
233
292
|
async flush(): Promise<void> {
|
|
234
293
|
if (!this.active) return;
|
|
294
|
+
this.consoleCapture?.flushPending(true);
|
|
235
295
|
this.retryAfter = 0;
|
|
236
296
|
if (this.drainPromise) {
|
|
237
297
|
await this.drainPromise;
|
|
@@ -261,6 +321,8 @@ export class MidlineAgent {
|
|
|
261
321
|
clearInterval(this.timer);
|
|
262
322
|
this.timer = null;
|
|
263
323
|
}
|
|
324
|
+
this.consoleCapture?.uninstall();
|
|
325
|
+
this.consoleCapture = null;
|
|
264
326
|
this.queue = [];
|
|
265
327
|
this.queueBytes = 0;
|
|
266
328
|
this.transport?.destroy();
|
|
@@ -306,6 +368,7 @@ export class MidlineAgent {
|
|
|
306
368
|
private toWire(event: MidlineEvent, preRedacted: boolean): Record<string, unknown> {
|
|
307
369
|
const config = this.config!;
|
|
308
370
|
const type = event.type;
|
|
371
|
+
const isConsole = type === "console";
|
|
309
372
|
const statusCode = Number.isInteger(event.statusCode) && event.statusCode! >= 100 && event.statusCode! <= 599
|
|
310
373
|
? event.statusCode
|
|
311
374
|
: type === "error" ? 500 : undefined;
|
|
@@ -313,7 +376,9 @@ export class MidlineAgent {
|
|
|
313
376
|
const route = this.redactor.string(stripQuery(event.path) || "/", 2048);
|
|
314
377
|
|
|
315
378
|
const payload: Record<string, unknown> = {};
|
|
316
|
-
if (
|
|
379
|
+
if (isConsole) {
|
|
380
|
+
payload.message = this.redactor.string(event.message ?? "", MAX_LINE_CHARS);
|
|
381
|
+
} else if (type === "error" || event.message) {
|
|
317
382
|
payload.error = event.message ? this.redactor.string(event.message, 1024) : type === "error" ? "Unknown error" : undefined;
|
|
318
383
|
}
|
|
319
384
|
if (event.stack) payload.stack = this.redactor.string(event.stack, 16 * 1024);
|
|
@@ -349,9 +414,10 @@ export class MidlineAgent {
|
|
|
349
414
|
apiKey: config.apiKey,
|
|
350
415
|
eventType: type,
|
|
351
416
|
route,
|
|
352
|
-
|
|
417
|
+
// A printed line has no method or timing; defaults here would make it look like a request.
|
|
418
|
+
method: isConsole ? undefined : (event.method || "GET").toUpperCase().slice(0, 16),
|
|
353
419
|
statusCode,
|
|
354
|
-
responseTime: Math.min(Math.max(0, Math.round(Number(event.duration) || 0)), 86_400_000),
|
|
420
|
+
responseTime: isConsole ? undefined : Math.min(Math.max(0, Math.round(Number(event.duration) || 0)), 86_400_000),
|
|
355
421
|
timestamp: validTimestamp(event.timestamp),
|
|
356
422
|
// Clamped to the server's validation limits: one over-long field would
|
|
357
423
|
// otherwise get every event rejected.
|
|
@@ -470,6 +536,7 @@ export class MidlineAgent {
|
|
|
470
536
|
}
|
|
471
537
|
if (outcome.kind === "rejected") {
|
|
472
538
|
if (batch.length === 1) {
|
|
539
|
+
if (this.refusedConsole(batch[0], outcome.detail)) continue;
|
|
473
540
|
this.dropped += 1;
|
|
474
541
|
this.report(`rejected:${outcome.detail}`, `midline: the Midline server rejected an event (${outcome.detail}); dropped it.`);
|
|
475
542
|
continue;
|
|
@@ -490,12 +557,14 @@ export class MidlineAgent {
|
|
|
490
557
|
/** Returns false if delivery should stop for this drain. */
|
|
491
558
|
private async sendIndividually(batch: QueuedEvent[], keepProcessAlive: boolean): Promise<boolean> {
|
|
492
559
|
for (let index = 0; index < batch.length; index++) {
|
|
560
|
+
if (this.consoleUnsupported && batch[index].wire.eventType === "console") continue;
|
|
493
561
|
const outcome = await this.send([batch[index]], keepProcessAlive);
|
|
494
562
|
if (outcome.kind === "ok") {
|
|
495
563
|
this.onSuccess();
|
|
496
564
|
} else if (outcome.kind === "rejected" || outcome.kind === "tooLarge") {
|
|
497
|
-
this.dropped += 1;
|
|
498
565
|
const detail = outcome.kind === "rejected" ? outcome.detail : "HTTP 413";
|
|
566
|
+
if (outcome.kind === "rejected" && this.refusedConsole(batch[index], detail)) continue;
|
|
567
|
+
this.dropped += 1;
|
|
499
568
|
this.report(`rejected:${detail}`, `midline: the Midline server rejected an event (${detail}); dropped it.`);
|
|
500
569
|
} else if (outcome.kind === "stop") {
|
|
501
570
|
this.disable();
|
|
@@ -565,6 +634,27 @@ export class MidlineAgent {
|
|
|
565
634
|
return { kind: "rejected", detail: `HTTP ${status}${serverMessage(result.body)}` };
|
|
566
635
|
}
|
|
567
636
|
|
|
637
|
+
/**
|
|
638
|
+
* True when a rejected event is a console line the server has no event type for,
|
|
639
|
+
* i.e. the server predates console capture. Capture switches itself off rather
|
|
640
|
+
* than paying a refused request for every line printed from then on.
|
|
641
|
+
*/
|
|
642
|
+
private refusedConsole(item: QueuedEvent, detail: string): boolean {
|
|
643
|
+
if (item.wire.eventType !== "console" || !detail.includes("eventType")) return false;
|
|
644
|
+
if (!this.consoleUnsupported) {
|
|
645
|
+
this.consoleUnsupported = true;
|
|
646
|
+
this.consoleCapture?.uninstall();
|
|
647
|
+
this.consoleCapture = null;
|
|
648
|
+
this.queue = this.queue.filter((queued) => queued.wire.eventType !== "console");
|
|
649
|
+
this.queueBytes = this.queue.reduce((sum, queued) => sum + queued.bytes, 0);
|
|
650
|
+
this.log(
|
|
651
|
+
"warn",
|
|
652
|
+
"midline: this Midline server does not accept console events yet (it needs updating), so console capture is off. Request and error monitoring carry on.",
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
return true;
|
|
656
|
+
}
|
|
657
|
+
|
|
568
658
|
/** One line per distinct fault, not one per failed event. */
|
|
569
659
|
private report(signature: string, message: string): void {
|
|
570
660
|
if (this.lastNotice === signature && !this.debugLogs) {
|
|
@@ -638,17 +728,21 @@ export class MidlineAgent {
|
|
|
638
728
|
}
|
|
639
729
|
|
|
640
730
|
private log(level: "info" | "warn" | "error", message: string): void {
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
731
|
+
// The agent's own diagnostics are never the application's console output,
|
|
732
|
+
// including when a hook hands them to a logger that prints them.
|
|
733
|
+
withoutConsoleCapture(() => {
|
|
734
|
+
if (this.onErrorHook) {
|
|
735
|
+
try {
|
|
736
|
+
this.onErrorHook(message);
|
|
737
|
+
} catch {
|
|
738
|
+
// A broken logging hook must not become the host application's problem.
|
|
739
|
+
}
|
|
740
|
+
if (!this.debugLogs) return;
|
|
646
741
|
}
|
|
647
|
-
if (
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
else console.info(message);
|
|
742
|
+
if (level === "error") console.error(message);
|
|
743
|
+
else if (level === "warn") console.warn(message);
|
|
744
|
+
else console.info(message);
|
|
745
|
+
});
|
|
652
746
|
}
|
|
653
747
|
}
|
|
654
748
|
|
|
@@ -667,6 +761,8 @@ function classify(event: MidlineEvent, statusCode: number | undefined): { severi
|
|
|
667
761
|
category = "security";
|
|
668
762
|
} else if (event.type === "custom") {
|
|
669
763
|
category = "business";
|
|
764
|
+
} else if (event.type === "console") {
|
|
765
|
+
category = "application";
|
|
670
766
|
} else if (statusCode && statusCode >= 500) {
|
|
671
767
|
severity = "high";
|
|
672
768
|
category = "application";
|
package/src/config.ts
CHANGED
|
@@ -37,6 +37,7 @@ export interface ResolvedConfig {
|
|
|
37
37
|
redactFields: string[];
|
|
38
38
|
redactHeaders: string[];
|
|
39
39
|
capture: ResolvedCapture;
|
|
40
|
+
captureConsole: boolean;
|
|
40
41
|
onError?: (message: string) => void;
|
|
41
42
|
debug: boolean;
|
|
42
43
|
flushIntervalMs: number;
|
|
@@ -218,6 +219,7 @@ export function resolveConfig(config: MidlineConfig): ResolvedConfig {
|
|
|
218
219
|
redactFields: [...(config.redactFields ?? []), ...(config.maskFields ?? [])],
|
|
219
220
|
redactHeaders: config.redactHeaders ?? [],
|
|
220
221
|
capture: resolveCapture(config.capture),
|
|
222
|
+
captureConsole: config.captureConsole ?? envFlag("MIDLINE_CAPTURE_CONSOLE") ?? false,
|
|
221
223
|
onError: config.onError,
|
|
222
224
|
debug: config.debug ?? envFlag("MIDLINE_DEBUG") ?? false,
|
|
223
225
|
flushIntervalMs: positiveInt(config.flushIntervalMs, 1500, 50, 60_000),
|
package/src/console.ts
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Console capture: what the host process prints — `console.log`, Nest's logger,
|
|
3
|
+
* pino, anything that reaches stdout or stderr — copied into Midline line by line.
|
|
4
|
+
*
|
|
5
|
+
* The streams are never changed. The original `write` runs first with the
|
|
6
|
+
* caller's own arguments and its return value is handed straight back, so
|
|
7
|
+
* back-pressure, callbacks and the terminal output are exactly what they were.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export type ConsoleStream = "stdout" | "stderr";
|
|
11
|
+
export type ConsoleLevel = "info" | "warn" | "error";
|
|
12
|
+
|
|
13
|
+
export interface ConsoleLine {
|
|
14
|
+
stream: ConsoleStream;
|
|
15
|
+
text: string;
|
|
16
|
+
level: ConsoleLevel;
|
|
17
|
+
/** When the line's first chunk was written, not when it was sent. */
|
|
18
|
+
timestamp: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Longest line kept. A log line past this is almost always a dumped payload. */
|
|
22
|
+
export const MAX_LINE_CHARS = 4096;
|
|
23
|
+
|
|
24
|
+
const STREAMS: ConsoleStream[] = ["stdout", "stderr"];
|
|
25
|
+
|
|
26
|
+
/** CSI (colours, cursor moves), OSC (titles, hyperlinks) and the remaining two-byte escapes. */
|
|
27
|
+
const ANSI_ESCAPE = /\x1B\[[0-?]*[ -\/]*[@-~]|\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)|\x1B[@-Z\\-_]/g;
|
|
28
|
+
|
|
29
|
+
// Level words are matched in capitals only: lower-case "error" turns up in plenty of harmless lines.
|
|
30
|
+
const ERROR_MARKERS = [/\b(?:ERROR|FATAL|CRITICAL)\b/, /"level"\s*:\s*"?(?:error|fatal|critical|50|60)\b/i];
|
|
31
|
+
const WARN_MARKERS = [/\bWARN(?:ING)?\b/, /"level"\s*:\s*"?(?:warn|warning|40)\b/i];
|
|
32
|
+
|
|
33
|
+
type WriteFn = (...args: any[]) => boolean;
|
|
34
|
+
|
|
35
|
+
let suppressed = 0;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Runs `fn` with capture paused. The agent writes its own diagnostics through
|
|
39
|
+
* this, so a warning about delivery can't become an event that needs delivering.
|
|
40
|
+
*/
|
|
41
|
+
export function withoutConsoleCapture<T>(fn: () => T): T {
|
|
42
|
+
suppressed += 1;
|
|
43
|
+
try {
|
|
44
|
+
return fn();
|
|
45
|
+
} finally {
|
|
46
|
+
suppressed -= 1;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
interface Pending {
|
|
51
|
+
text: string;
|
|
52
|
+
at: string;
|
|
53
|
+
/** Already unfinished at the previous flush. */
|
|
54
|
+
stale: boolean;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export class ConsoleCapture {
|
|
58
|
+
private readonly originals = new Map<ConsoleStream, WriteFn>();
|
|
59
|
+
private readonly wrappers = new Map<ConsoleStream, WriteFn>();
|
|
60
|
+
private readonly pending: Record<ConsoleStream, Pending> = {
|
|
61
|
+
stdout: { text: "", at: "", stale: false },
|
|
62
|
+
stderr: { text: "", at: "", stale: false },
|
|
63
|
+
};
|
|
64
|
+
private busy = false;
|
|
65
|
+
private stopped = false;
|
|
66
|
+
|
|
67
|
+
constructor(private readonly onLine: (line: ConsoleLine) => void) {}
|
|
68
|
+
|
|
69
|
+
install(): void {
|
|
70
|
+
for (const stream of STREAMS) {
|
|
71
|
+
const target = process[stream];
|
|
72
|
+
const original = target.write as WriteFn;
|
|
73
|
+
const capture = this;
|
|
74
|
+
const wrapper: WriteFn = function (this: unknown, ...args: any[]) {
|
|
75
|
+
const result = original.apply(this, args);
|
|
76
|
+
capture.take(stream, args[0], args[1]);
|
|
77
|
+
return result;
|
|
78
|
+
};
|
|
79
|
+
this.originals.set(stream, original);
|
|
80
|
+
this.wrappers.set(stream, wrapper);
|
|
81
|
+
target.write = wrapper as NodeJS.WriteStream["write"];
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Restores the streams. A wrapper installed on top of ours is left alone — ours just goes quiet underneath it. */
|
|
86
|
+
uninstall(): void {
|
|
87
|
+
this.stopped = true;
|
|
88
|
+
for (const stream of STREAMS) {
|
|
89
|
+
const wrapper = this.wrappers.get(stream);
|
|
90
|
+
if (wrapper && process[stream].write === wrapper) {
|
|
91
|
+
process[stream].write = this.originals.get(stream) as NodeJS.WriteStream["write"];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
this.wrappers.clear();
|
|
95
|
+
this.originals.clear();
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Emits lines still waiting for a newline. Without `force`, only the ones that
|
|
100
|
+
* were already unfinished at the previous call, so a line being written in
|
|
101
|
+
* pieces isn't cut in half by a timer tick.
|
|
102
|
+
*/
|
|
103
|
+
flushPending(force = false): void {
|
|
104
|
+
if (this.stopped || this.busy) return;
|
|
105
|
+
this.busy = true;
|
|
106
|
+
try {
|
|
107
|
+
for (const stream of STREAMS) {
|
|
108
|
+
const pending = this.pending[stream];
|
|
109
|
+
if (!pending.text) continue;
|
|
110
|
+
if (force || pending.stale) {
|
|
111
|
+
this.pending[stream] = { text: "", at: "", stale: false };
|
|
112
|
+
this.emit(stream, pending.text, pending.at);
|
|
113
|
+
} else {
|
|
114
|
+
pending.stale = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
} catch {
|
|
118
|
+
// Best-effort, like the rest of capture.
|
|
119
|
+
} finally {
|
|
120
|
+
this.busy = false;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
private take(stream: ConsoleStream, chunk: unknown, encoding: unknown): void {
|
|
125
|
+
// `busy` also stops a loop if whatever handles a line prints something itself.
|
|
126
|
+
if (this.stopped || this.busy || suppressed > 0) return;
|
|
127
|
+
this.busy = true;
|
|
128
|
+
try {
|
|
129
|
+
const text = decode(chunk, encoding);
|
|
130
|
+
if (!text) return;
|
|
131
|
+
|
|
132
|
+
const now = new Date().toISOString();
|
|
133
|
+
const pending = this.pending[stream];
|
|
134
|
+
const lines = (pending.text + text).split("\n");
|
|
135
|
+
let rest = lines.pop() ?? "";
|
|
136
|
+
let at = pending.text ? pending.at : now;
|
|
137
|
+
|
|
138
|
+
for (const line of lines) {
|
|
139
|
+
this.emit(stream, line, at);
|
|
140
|
+
at = now;
|
|
141
|
+
}
|
|
142
|
+
if (rest.length > MAX_LINE_CHARS) {
|
|
143
|
+
this.emit(stream, rest, at);
|
|
144
|
+
rest = "";
|
|
145
|
+
}
|
|
146
|
+
this.pending[stream] = { text: rest, at: rest ? at : "", stale: false };
|
|
147
|
+
} catch {
|
|
148
|
+
// The write itself already happened; a line we couldn't read is just not sent.
|
|
149
|
+
} finally {
|
|
150
|
+
this.busy = false;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
private emit(stream: ConsoleStream, raw: string, at: string): void {
|
|
155
|
+
let text = raw.replace(ANSI_ESCAPE, "").replace(/\r$/, "");
|
|
156
|
+
// A carriage return redraws the line; what's left after the last one is what the terminal shows.
|
|
157
|
+
text = text.slice(text.lastIndexOf("\r") + 1);
|
|
158
|
+
if (!text.trim()) return;
|
|
159
|
+
if (text.length > MAX_LINE_CHARS) text = text.slice(0, MAX_LINE_CHARS);
|
|
160
|
+
this.onLine({ stream, text, level: levelOf(stream, text), timestamp: at });
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Nest, most JSON loggers and console.warn/error leave enough behind to tell a failure from chatter. */
|
|
165
|
+
export function levelOf(stream: ConsoleStream, text: string): ConsoleLevel {
|
|
166
|
+
const head = text.slice(0, 256);
|
|
167
|
+
if (ERROR_MARKERS.some((pattern) => pattern.test(head))) return "error";
|
|
168
|
+
if (WARN_MARKERS.some((pattern) => pattern.test(head))) return "warn";
|
|
169
|
+
return stream === "stderr" ? "warn" : "info";
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function decode(chunk: unknown, encoding: unknown): string {
|
|
173
|
+
if (typeof chunk === "string") {
|
|
174
|
+
return typeof encoding === "string" && !/^utf-?8$/i.test(encoding) && Buffer.isEncoding(encoding)
|
|
175
|
+
? Buffer.from(chunk, encoding).toString("utf8")
|
|
176
|
+
: chunk;
|
|
177
|
+
}
|
|
178
|
+
if (chunk instanceof Uint8Array) {
|
|
179
|
+
return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength).toString("utf8");
|
|
180
|
+
}
|
|
181
|
+
return "";
|
|
182
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/** A PEM string, PEM bytes, a path to a PEM file, or several of those. */
|
|
2
2
|
export type CaInput = string | Buffer | Array<string | Buffer>;
|
|
3
3
|
|
|
4
|
-
export type EventType = "request" | "error" | "security" | "performance" | "custom";
|
|
4
|
+
export type EventType = "request" | "error" | "security" | "performance" | "custom" | "console";
|
|
5
5
|
export type EventSeverity = "low" | "medium" | "high" | "critical";
|
|
6
6
|
export type EventCategory = "application" | "infrastructure" | "security" | "performance" | "business";
|
|
7
7
|
|
|
@@ -62,6 +62,13 @@ export interface MidlineConfig {
|
|
|
62
62
|
redactHeaders?: string[];
|
|
63
63
|
/** What to capture beyond method/path/status/timing. Defaults to nothing. */
|
|
64
64
|
capture?: CaptureOptions;
|
|
65
|
+
/**
|
|
66
|
+
* Also send what the process prints — `console.log`, Nest's logger, anything
|
|
67
|
+
* written to stdout or stderr — as `console` events, one per line, redacted like
|
|
68
|
+
* any other text. Initialise the agent before creating the app to include its
|
|
69
|
+
* startup lines. Off by default. Env fallback: `MIDLINE_CAPTURE_CONSOLE`.
|
|
70
|
+
*/
|
|
71
|
+
captureConsole?: boolean;
|
|
65
72
|
|
|
66
73
|
/** Set to false to keep the agent inert. Env fallback: `MIDLINE_ENABLED`. */
|
|
67
74
|
enabled?: boolean;
|
|
@@ -141,5 +148,5 @@ export interface MidlineEvent {
|
|
|
141
148
|
errorCode?: string;
|
|
142
149
|
/** Set when the client disconnected before the response finished. */
|
|
143
150
|
aborted?: boolean;
|
|
144
|
-
integration?: "express" | "node-http" | "proxy" | "manual";
|
|
151
|
+
integration?: "express" | "node-http" | "proxy" | "console" | "manual";
|
|
145
152
|
}
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const { MidlineAgent } = require("../dist");
|
|
6
|
+
const { startCollector, diagnostics } = require("./helpers");
|
|
7
|
+
|
|
8
|
+
function agentFor(endpoint, extra = {}) {
|
|
9
|
+
return new MidlineAgent({
|
|
10
|
+
apiKey: "ak_test_key",
|
|
11
|
+
serviceName: "console-test",
|
|
12
|
+
endpoint,
|
|
13
|
+
flushIntervalMs: 60_000,
|
|
14
|
+
timeoutMs: 2000,
|
|
15
|
+
connectTimeoutMs: 1000,
|
|
16
|
+
captureConsole: true,
|
|
17
|
+
...extra,
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Records every chunk that reaches the streams, then passes it on. Installed before
|
|
23
|
+
* the agent, so it sits underneath the agent's wrapper where the terminal would be.
|
|
24
|
+
*/
|
|
25
|
+
function recordStreams() {
|
|
26
|
+
const originals = { stdout: process.stdout.write, stderr: process.stderr.write };
|
|
27
|
+
const written = { stdout: [], stderr: [] };
|
|
28
|
+
const recorders = {};
|
|
29
|
+
for (const name of ["stdout", "stderr"]) {
|
|
30
|
+
recorders[name] = function (chunk, ...rest) {
|
|
31
|
+
written[name].push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"));
|
|
32
|
+
return originals[name].call(this, chunk, ...rest);
|
|
33
|
+
};
|
|
34
|
+
process[name].write = recorders[name];
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
written,
|
|
38
|
+
recorders,
|
|
39
|
+
restore: () => {
|
|
40
|
+
process.stdout.write = originals.stdout;
|
|
41
|
+
process.stderr.write = originals.stderr;
|
|
42
|
+
},
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const consoleEvents = (collector) => collector.events().filter((event) => event.eventType === "console");
|
|
47
|
+
|
|
48
|
+
test("console capture: printed lines become console events and the output itself is untouched", async () => {
|
|
49
|
+
const streams = recordStreams();
|
|
50
|
+
const collector = await startCollector();
|
|
51
|
+
const agent = agentFor(collector.url);
|
|
52
|
+
try {
|
|
53
|
+
const coloured = "\x1b[32m[Nest] 4242 - LOG [NestApplication] Nest application successfully started\x1b[39m\n";
|
|
54
|
+
process.stdout.write(coloured);
|
|
55
|
+
console.log("hello world");
|
|
56
|
+
process.stdout.write("written in ");
|
|
57
|
+
process.stdout.write("two pieces\n");
|
|
58
|
+
process.stderr.write("[Nest] 4242 - ERROR [ExceptionHandler] database unreachable\n");
|
|
59
|
+
console.log("connecting with password=hunter2");
|
|
60
|
+
process.stdout.write("\n \n");
|
|
61
|
+
await agent.flush();
|
|
62
|
+
|
|
63
|
+
const events = consoleEvents(collector);
|
|
64
|
+
const byMessage = new Map(events.map((event) => [event.payload.message, event]));
|
|
65
|
+
|
|
66
|
+
const started = byMessage.get("[Nest] 4242 - LOG [NestApplication] Nest application successfully started");
|
|
67
|
+
assert.ok(started, "colour codes are stripped");
|
|
68
|
+
assert.equal(started.route, "stdout");
|
|
69
|
+
assert.equal(started.severity, "low");
|
|
70
|
+
assert.equal(started.method, undefined, "a printed line is not dressed up as a request");
|
|
71
|
+
assert.equal(started.statusCode, undefined);
|
|
72
|
+
assert.equal(started.responseTime, undefined);
|
|
73
|
+
assert.equal(started.metadata.integrationType, "console");
|
|
74
|
+
|
|
75
|
+
assert.ok(byMessage.has("hello world"));
|
|
76
|
+
assert.ok(byMessage.has("written in two pieces"), "a line written in chunks is one event");
|
|
77
|
+
|
|
78
|
+
const failure = byMessage.get("[Nest] 4242 - ERROR [ExceptionHandler] database unreachable");
|
|
79
|
+
assert.ok(failure);
|
|
80
|
+
assert.equal(failure.route, "stderr");
|
|
81
|
+
assert.equal(failure.severity, "high");
|
|
82
|
+
|
|
83
|
+
assert.ok(byMessage.has("connecting with password=[REDACTED]"));
|
|
84
|
+
assert.ok(!events.some((event) => event.payload.message.includes("hunter2")));
|
|
85
|
+
assert.ok(!events.some((event) => !event.payload.message.trim()), "blank lines are skipped");
|
|
86
|
+
|
|
87
|
+
assert.ok(streams.written.stdout.includes(coloured), "the terminal still gets the original bytes");
|
|
88
|
+
assert.ok(streams.written.stdout.includes("connecting with password=hunter2\n"), "redaction applies to what's sent, not what's printed");
|
|
89
|
+
} finally {
|
|
90
|
+
agent.close();
|
|
91
|
+
streams.restore();
|
|
92
|
+
await collector.close();
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test("console capture: the agent's own warnings are not captured, and close() puts the streams back", async () => {
|
|
97
|
+
const streams = recordStreams();
|
|
98
|
+
const collector = await startCollector({ respond: () => ({ status: 503, body: { message: "down" } }) });
|
|
99
|
+
// No onError: the agent's diagnostics go to the console, where capture could see them.
|
|
100
|
+
const agent = agentFor(collector.url);
|
|
101
|
+
try {
|
|
102
|
+
assert.notEqual(process.stdout.write, streams.recorders.stdout, "capture wraps stdout");
|
|
103
|
+
console.log("before the outage");
|
|
104
|
+
await agent.flush();
|
|
105
|
+
assert.ok(streams.written.stderr.some((chunk) => chunk.includes("midline:")), "the warning was printed");
|
|
106
|
+
|
|
107
|
+
collector.setResponder((req) => ({ status: 201, body: { success: req.body.events.length, failed: 0 } }));
|
|
108
|
+
await agent.flush();
|
|
109
|
+
const messages = consoleEvents(collector).map((event) => event.payload.message);
|
|
110
|
+
assert.ok(messages.includes("before the outage"));
|
|
111
|
+
assert.ok(!messages.some((message) => message.includes("midline:")), "diagnostics never become events");
|
|
112
|
+
|
|
113
|
+
agent.close();
|
|
114
|
+
assert.equal(process.stdout.write, streams.recorders.stdout);
|
|
115
|
+
assert.equal(process.stderr.write, streams.recorders.stderr);
|
|
116
|
+
} finally {
|
|
117
|
+
agent.close();
|
|
118
|
+
streams.restore();
|
|
119
|
+
await collector.close();
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test("console capture: a server without console events turns capture off and requests keep flowing", async () => {
|
|
124
|
+
const streams = recordStreams();
|
|
125
|
+
const collector = await startCollector({
|
|
126
|
+
respond: (req) => {
|
|
127
|
+
const events = req.body.events;
|
|
128
|
+
if (events.some((event) => event.eventType === "console")) {
|
|
129
|
+
return {
|
|
130
|
+
status: 400,
|
|
131
|
+
body: { message: ["events.1.eventType must be one of the following values: request, error, security, performance, custom"] },
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
return { status: 201, body: { success: events.length, failed: 0 } };
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
const { messages, onError } = diagnostics();
|
|
138
|
+
const agent = agentFor(collector.url, { onError });
|
|
139
|
+
try {
|
|
140
|
+
agent.addEvent({ type: "request", path: "/orders" });
|
|
141
|
+
console.log("server started");
|
|
142
|
+
console.log("second line");
|
|
143
|
+
await agent.flush();
|
|
144
|
+
|
|
145
|
+
const accepted = collector.requests
|
|
146
|
+
.filter((request) => !request.body.events.some((event) => event.eventType === "console"))
|
|
147
|
+
.flatMap((request) => request.body.events.map((event) => event.route));
|
|
148
|
+
assert.deepEqual(accepted, ["/orders"]);
|
|
149
|
+
assert.equal(agent.queued, 0);
|
|
150
|
+
assert.equal(agent.active, true, "only console capture stops");
|
|
151
|
+
assert.match(messages.join("\n"), /console capture is off/);
|
|
152
|
+
assert.equal(process.stdout.write, streams.recorders.stdout, "the stream wrapper is gone");
|
|
153
|
+
|
|
154
|
+
const refused = collector.requests.filter((request) => request.body.events.some((event) => event.eventType === "console"));
|
|
155
|
+
assert.equal(refused.length, 2, "the batch, then one console line on its own; the rest are not sent");
|
|
156
|
+
} finally {
|
|
157
|
+
agent.close();
|
|
158
|
+
streams.restore();
|
|
159
|
+
await collector.close();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("console capture is off unless asked for, and MIDLINE_CAPTURE_CONSOLE turns it on", () => {
|
|
164
|
+
const before = process.stdout.write;
|
|
165
|
+
const settings = { apiKey: "ak_test_key", endpoint: "http://127.0.0.1:9", flushIntervalMs: 60_000 };
|
|
166
|
+
|
|
167
|
+
const off = new MidlineAgent(settings);
|
|
168
|
+
assert.equal(process.stdout.write, before);
|
|
169
|
+
off.close();
|
|
170
|
+
|
|
171
|
+
const previous = process.env.MIDLINE_CAPTURE_CONSOLE;
|
|
172
|
+
process.env.MIDLINE_CAPTURE_CONSOLE = "true";
|
|
173
|
+
const on = new MidlineAgent(settings);
|
|
174
|
+
try {
|
|
175
|
+
assert.notEqual(process.stdout.write, before);
|
|
176
|
+
} finally {
|
|
177
|
+
on.close();
|
|
178
|
+
if (previous === undefined) delete process.env.MIDLINE_CAPTURE_CONSOLE;
|
|
179
|
+
else process.env.MIDLINE_CAPTURE_CONSOLE = previous;
|
|
180
|
+
}
|
|
181
|
+
assert.equal(process.stdout.write, before);
|
|
182
|
+
});
|