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 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/error events to the Midline server.
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/error events to the Midline server.
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 (type === "error" || event.message) {
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
- method: (event.method || "GET").toUpperCase().slice(0, 16),
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
- if (this.onErrorHook) {
562
- try {
563
- this.onErrorHook(message);
564
- }
565
- catch {
566
- // A broken logging hook must not become the host application's problem.
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 (!this.debugLogs)
569
- return;
570
- }
571
- if (level === "error")
572
- console.error(message);
573
- else if (level === "warn")
574
- console.warn(message);
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
@@ -27,6 +27,7 @@ export interface ResolvedConfig {
27
27
  redactFields: string[];
28
28
  redactHeaders: string[];
29
29
  capture: ResolvedCapture;
30
+ captureConsole: boolean;
30
31
  onError?: (message: string) => void;
31
32
  debug: boolean;
32
33
  flushIntervalMs: number;
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;
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "midline-agent",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Midline — request & error monitoring with security detection",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
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/error events to the Midline server.
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 (type === "error" || event.message) {
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
- method: (event.method || "GET").toUpperCase().slice(0, 16),
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
- if (this.onErrorHook) {
642
- try {
643
- this.onErrorHook(message);
644
- } catch {
645
- // A broken logging hook must not become the host application's problem.
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 (!this.debugLogs) return;
648
- }
649
- if (level === "error") console.error(message);
650
- else if (level === "warn") console.warn(message);
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
+ });