midline-agent 0.1.9 → 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 +313 -249
- package/dist/agent.d.ts +118 -6
- package/dist/agent.js +746 -99
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +63 -0
- package/dist/config.d.ts +64 -0
- package/dist/config.js +231 -0
- package/dist/console.d.ts +46 -0
- package/dist/console.js +167 -0
- package/dist/context.d.ts +13 -0
- package/dist/context.js +38 -0
- package/dist/errorHandler.d.ts +11 -2
- package/dist/errorHandler.js +32 -12
- package/dist/index.d.ts +9 -2
- package/dist/index.js +9 -1
- package/dist/middleware.d.ts +19 -2
- package/dist/middleware.js +92 -41
- package/dist/proxy.d.ts +70 -0
- package/dist/proxy.js +383 -0
- package/dist/redact.d.ts +35 -0
- package/dist/redact.js +223 -0
- package/dist/tap.d.ts +18 -0
- package/dist/tap.js +62 -0
- package/dist/transport.d.ts +35 -0
- package/dist/transport.js +133 -0
- package/dist/types.d.ts +114 -5
- package/package.json +13 -8
- package/src/agent.ts +825 -97
- package/src/cli.ts +69 -0
- package/src/config.ts +234 -0
- package/src/console.ts +182 -0
- package/src/context.ts +48 -0
- package/src/errorHandler.ts +36 -13
- package/src/index.ts +19 -2
- package/src/middleware.ts +118 -43
- package/src/proxy.ts +435 -0
- package/src/redact.ts +231 -0
- package/src/tap.ts +55 -0
- package/src/transport.ts +125 -0
- package/src/types.ts +151 -31
- package/test/agent.test.js +353 -0
- package/test/console.test.js +182 -0
- package/test/helpers.js +151 -0
- package/test/middleware.test.js +178 -0
- package/test/proxy.test.js +274 -0
- package/test/redact.test.js +105 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const tls = require("tls");
|
|
6
|
+
const { MidlineAgent } = require("../dist");
|
|
7
|
+
const { ConfigError, loadCa, resolveIngestUrl, trustStore } = require("../dist/config");
|
|
8
|
+
const { makeCerts, startCollector, closedPort, diagnostics } = require("./helpers");
|
|
9
|
+
|
|
10
|
+
const certs = makeCerts();
|
|
11
|
+
const needsOpenssl = certs ? {} : { skip: "openssl not available" };
|
|
12
|
+
|
|
13
|
+
function agentFor(endpoint, extra = {}) {
|
|
14
|
+
return new MidlineAgent({
|
|
15
|
+
apiKey: "ak_test_key",
|
|
16
|
+
serviceName: "agent-test",
|
|
17
|
+
endpoint,
|
|
18
|
+
flushIntervalMs: 60_000,
|
|
19
|
+
timeoutMs: 2000,
|
|
20
|
+
connectTimeoutMs: 1000,
|
|
21
|
+
...extra,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
test("endpoint: a base URL gets the ingest path, a full URL is kept", () => {
|
|
26
|
+
assert.equal(resolveIngestUrl("https://api.usemidline.com").href, "https://api.usemidline.com/api/api-monitor/events");
|
|
27
|
+
assert.equal(resolveIngestUrl("https://api.usemidline.com/").href, "https://api.usemidline.com/api/api-monitor/events");
|
|
28
|
+
assert.equal(
|
|
29
|
+
resolveIngestUrl("https://api.usemidline.com/api/api-monitor/events").href,
|
|
30
|
+
"https://api.usemidline.com/api/api-monitor/events",
|
|
31
|
+
);
|
|
32
|
+
assert.equal(
|
|
33
|
+
resolveIngestUrl("https://api.usemidline.com/api/api-monitor/events/batch").href,
|
|
34
|
+
"https://api.usemidline.com/api/api-monitor/events",
|
|
35
|
+
);
|
|
36
|
+
assert.equal(resolveIngestUrl("https://gw.internal/midline").href, "https://gw.internal/midline/api/api-monitor/events");
|
|
37
|
+
assert.equal(resolveIngestUrl("http://localhost:8000").href, "http://localhost:8000/api/api-monitor/events");
|
|
38
|
+
assert.equal(resolveIngestUrl("http://127.0.0.1:8000").href, "http://127.0.0.1:8000/api/api-monitor/events");
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
test("endpoint: plain http to a non-loopback host is refused, so the key never travels in cleartext", () => {
|
|
42
|
+
assert.throws(() => resolveIngestUrl("http://api.usemidline.com"), ConfigError);
|
|
43
|
+
assert.throws(() => resolveIngestUrl("http://10.0.0.5:8000"), /cleartext/);
|
|
44
|
+
assert.throws(() => resolveIngestUrl("ftp://api.usemidline.com"), ConfigError);
|
|
45
|
+
assert.throws(() => resolveIngestUrl("https://user:pass@api.usemidline.com"), /credentials/);
|
|
46
|
+
|
|
47
|
+
const { messages, onError } = diagnostics();
|
|
48
|
+
const agent = new MidlineAgent({ apiKey: "ak_x", endpoint: "http://api.usemidline.com", onError });
|
|
49
|
+
assert.equal(agent.active, false);
|
|
50
|
+
assert.match(messages[0], /cleartext/);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("agent without an API key stays off and says so once", () => {
|
|
54
|
+
const { messages, onError } = diagnostics();
|
|
55
|
+
const previous = process.env.MIDLINE_API_KEY;
|
|
56
|
+
delete process.env.MIDLINE_API_KEY;
|
|
57
|
+
const agent = new MidlineAgent({ onError });
|
|
58
|
+
assert.equal(agent.active, false);
|
|
59
|
+
agent.addEvent({ type: "request", path: "/" });
|
|
60
|
+
assert.equal(agent.queued, 0);
|
|
61
|
+
assert.equal(messages.length, 1);
|
|
62
|
+
if (previous !== undefined) process.env.MIDLINE_API_KEY = previous;
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("custom CA: appended to the default trust store, bad input fails loudly", needsOpenssl, () => {
|
|
66
|
+
const extra = loadCa(certs.ca, "ca");
|
|
67
|
+
const store = trustStore(extra);
|
|
68
|
+
assert.ok(store.length > tls.rootCertificates.length, "public roots are kept");
|
|
69
|
+
assert.equal(trustStore(undefined), undefined, "no CA means Node's defaults, untouched");
|
|
70
|
+
assert.deepEqual(loadCa(certs.caPath, "ca")[0].toString(), certs.ca, "a file path works");
|
|
71
|
+
assert.throws(() => loadCa("/nope/ca.pem", "ca"), /cannot read CA file/);
|
|
72
|
+
assert.throws(() => loadCa("-----BEGIN CERTIFICATE-----\ngarbage\n-----END CERTIFICATE-----", "ca"), /not a parseable/);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
test("TLS: a self-signed Midline server is refused, events stay buffered, the app is unaffected", needsOpenssl, async () => {
|
|
76
|
+
const collector = await startCollector({ tls: certs.selfSigned });
|
|
77
|
+
const { messages, onError } = diagnostics();
|
|
78
|
+
const agent = agentFor(collector.url, { onError });
|
|
79
|
+
try {
|
|
80
|
+
agent.addEvent({ type: "request", path: "/hello", method: "GET", statusCode: 200 });
|
|
81
|
+
await agent.flush();
|
|
82
|
+
|
|
83
|
+
assert.equal(collector.requests.length, 0, "nothing was sent over an unverified connection");
|
|
84
|
+
assert.equal(agent.queued, 1, "the event is kept for retry");
|
|
85
|
+
assert.equal(agent.active, true, "a TLS fault does not permanently disable the agent");
|
|
86
|
+
assert.equal(messages.length, 1);
|
|
87
|
+
assert.match(messages[0], /DEPTH_ZERO_SELF_SIGNED_CERT/);
|
|
88
|
+
assert.match(messages[0], /verification stays on/);
|
|
89
|
+
} finally {
|
|
90
|
+
agent.close();
|
|
91
|
+
await collector.close();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("TLS: a private CA passed via `ca` is trusted for the Midline server", needsOpenssl, async () => {
|
|
96
|
+
const collector = await startCollector({ tls: certs.trusted });
|
|
97
|
+
const agent = agentFor(collector.url, { ca: certs.ca });
|
|
98
|
+
try {
|
|
99
|
+
agent.addEvent({ type: "request", path: "/hello", method: "GET", statusCode: 200 });
|
|
100
|
+
await agent.flush();
|
|
101
|
+
assert.equal(collector.requests.length, 1);
|
|
102
|
+
assert.equal(collector.requests[0].url, "/api/api-monitor/events/batch");
|
|
103
|
+
assert.equal(collector.requests[0].headers["x-api-key"], "ak_test_key");
|
|
104
|
+
assert.equal(agent.queued, 0);
|
|
105
|
+
} finally {
|
|
106
|
+
agent.close();
|
|
107
|
+
await collector.close();
|
|
108
|
+
}
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test("TLS: a trusted CA still does not excuse a certificate for the wrong hostname", needsOpenssl, async () => {
|
|
112
|
+
const collector = await startCollector({ tls: certs.wrongHost });
|
|
113
|
+
const { messages, onError } = diagnostics();
|
|
114
|
+
const agent = agentFor(collector.url, { ca: certs.ca, onError });
|
|
115
|
+
try {
|
|
116
|
+
agent.addEvent({ type: "request", path: "/hello" });
|
|
117
|
+
await agent.flush();
|
|
118
|
+
assert.equal(collector.requests.length, 0);
|
|
119
|
+
assert.match(messages[0], /ERR_TLS_CERT_ALTNAME_INVALID/);
|
|
120
|
+
} finally {
|
|
121
|
+
agent.close();
|
|
122
|
+
await collector.close();
|
|
123
|
+
}
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("MIDLINE_CUSTOM_CA (file path) is honoured", needsOpenssl, async () => {
|
|
127
|
+
const collector = await startCollector({ tls: certs.trusted });
|
|
128
|
+
process.env.MIDLINE_CUSTOM_CA = certs.caPath;
|
|
129
|
+
const agent = agentFor(collector.url);
|
|
130
|
+
try {
|
|
131
|
+
agent.addEvent({ type: "request", path: "/env-ca" });
|
|
132
|
+
await agent.flush();
|
|
133
|
+
assert.equal(collector.requests.length, 1);
|
|
134
|
+
} finally {
|
|
135
|
+
delete process.env.MIDLINE_CUSTOM_CA;
|
|
136
|
+
agent.close();
|
|
137
|
+
await collector.close();
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test("wire format stays inside the long-standing ingest schema", async () => {
|
|
142
|
+
const collector = await startCollector();
|
|
143
|
+
const agent = agentFor(collector.url, { environment: "test", release: "v1" });
|
|
144
|
+
try {
|
|
145
|
+
agent.addEvent({
|
|
146
|
+
type: "error",
|
|
147
|
+
path: "/charges?token=abc123",
|
|
148
|
+
method: "post",
|
|
149
|
+
statusCode: 502,
|
|
150
|
+
message: "upstream said Bearer abcdefghijklmnop",
|
|
151
|
+
stack: "Error: boom\n at x",
|
|
152
|
+
requestId: "req-1",
|
|
153
|
+
correlationId: "corr-1",
|
|
154
|
+
});
|
|
155
|
+
await agent.flush();
|
|
156
|
+
|
|
157
|
+
const [event] = collector.events();
|
|
158
|
+
const allowed = new Set([
|
|
159
|
+
"apiKey", "eventType", "severity", "category", "route", "method", "statusCode", "responseTime", "payload",
|
|
160
|
+
"timestamp", "ip", "userAgent", "metadata", "environment", "service", "host", "region", "traceId", "spanId",
|
|
161
|
+
"threatDetected", "ruleId", "release",
|
|
162
|
+
]);
|
|
163
|
+
for (const key of Object.keys(event)) {
|
|
164
|
+
assert.ok(allowed.has(key), `unexpected top-level field ${key} would be rejected by older servers`);
|
|
165
|
+
}
|
|
166
|
+
assert.equal(event.route, "/charges", "query strings never go in the route");
|
|
167
|
+
assert.equal(event.method, "POST");
|
|
168
|
+
assert.equal(event.eventType, "error");
|
|
169
|
+
assert.equal(event.payload.error, "upstream said Bearer [REDACTED]");
|
|
170
|
+
assert.equal(event.metadata.requestId, "req-1");
|
|
171
|
+
assert.equal(event.metadata.correlationId, "corr-1");
|
|
172
|
+
assert.equal(event.environment, "test");
|
|
173
|
+
} finally {
|
|
174
|
+
agent.close();
|
|
175
|
+
await collector.close();
|
|
176
|
+
}
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
test("5xx and 429 are retried with backoff and keep the events", async () => {
|
|
180
|
+
const collector = await startCollector({
|
|
181
|
+
respond: () => ({ status: 503, body: { message: "down" }, headers: { "retry-after": "1" } }),
|
|
182
|
+
});
|
|
183
|
+
const { messages, onError } = diagnostics();
|
|
184
|
+
const agent = agentFor(collector.url, { onError });
|
|
185
|
+
try {
|
|
186
|
+
agent.addEvent({ type: "request", path: "/a" });
|
|
187
|
+
agent.addEvent({ type: "request", path: "/b" });
|
|
188
|
+
await agent.flush();
|
|
189
|
+
assert.equal(agent.queued, 2);
|
|
190
|
+
assert.equal(collector.requests.length, 1, "one attempt per drain, not one per event");
|
|
191
|
+
|
|
192
|
+
collector.setResponder(() => ({ status: 201, body: { success: 2, failed: 0 } }));
|
|
193
|
+
await agent.flush();
|
|
194
|
+
assert.equal(agent.queued, 0);
|
|
195
|
+
assert.match(messages.at(-1), /recovered/);
|
|
196
|
+
} finally {
|
|
197
|
+
agent.close();
|
|
198
|
+
await collector.close();
|
|
199
|
+
}
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
test("401 turns the agent off instead of retrying forever", async () => {
|
|
203
|
+
const collector = await startCollector({ respond: () => ({ status: 401, body: { message: "Invalid API key" } }) });
|
|
204
|
+
const { onError } = diagnostics();
|
|
205
|
+
const agent = agentFor(collector.url, { onError });
|
|
206
|
+
try {
|
|
207
|
+
agent.addEvent({ type: "request", path: "/a" });
|
|
208
|
+
await agent.flush();
|
|
209
|
+
assert.equal(agent.active, false);
|
|
210
|
+
agent.addEvent({ type: "request", path: "/b" });
|
|
211
|
+
assert.equal(agent.queued, 0);
|
|
212
|
+
} finally {
|
|
213
|
+
agent.close();
|
|
214
|
+
await collector.close();
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
test("an older server's 201 with every event failed is treated as a rejected key", async () => {
|
|
219
|
+
const collector = await startCollector({ respond: (req) => ({ status: 201, body: { success: 0, failed: req.body.events.length } }) });
|
|
220
|
+
const { onError } = diagnostics();
|
|
221
|
+
const agent = agentFor(collector.url, { onError });
|
|
222
|
+
try {
|
|
223
|
+
agent.addEvent({ type: "request", path: "/a" });
|
|
224
|
+
await agent.flush();
|
|
225
|
+
assert.equal(agent.active, false);
|
|
226
|
+
} finally {
|
|
227
|
+
agent.close();
|
|
228
|
+
await collector.close();
|
|
229
|
+
}
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test("one malformed event costs only itself: a 400 batch is re-sent one by one", async () => {
|
|
233
|
+
const collector = await startCollector({
|
|
234
|
+
respond: (req) => {
|
|
235
|
+
const events = req.body.events;
|
|
236
|
+
if (events.some((event) => event.route === "/bad")) {
|
|
237
|
+
return { status: 400, body: { message: ["route must be shorter"] } };
|
|
238
|
+
}
|
|
239
|
+
return { status: 201, body: { success: events.length, failed: 0 } };
|
|
240
|
+
},
|
|
241
|
+
});
|
|
242
|
+
const { messages, onError } = diagnostics();
|
|
243
|
+
const agent = agentFor(collector.url, { onError });
|
|
244
|
+
try {
|
|
245
|
+
agent.addEvent({ type: "request", path: "/ok-1" });
|
|
246
|
+
agent.addEvent({ type: "request", path: "/bad" });
|
|
247
|
+
agent.addEvent({ type: "request", path: "/ok-2" });
|
|
248
|
+
await agent.flush();
|
|
249
|
+
|
|
250
|
+
const accepted = collector.requests
|
|
251
|
+
.filter((request) => !request.body.events.some((event) => event.route === "/bad"))
|
|
252
|
+
.flatMap((request) => request.body.events.map((event) => event.route));
|
|
253
|
+
assert.deepEqual(accepted, ["/ok-1", "/ok-2"]);
|
|
254
|
+
assert.equal(agent.queued, 0);
|
|
255
|
+
assert.match(messages.join("\n"), /route must be shorter/);
|
|
256
|
+
} finally {
|
|
257
|
+
agent.close();
|
|
258
|
+
await collector.close();
|
|
259
|
+
}
|
|
260
|
+
});
|
|
261
|
+
|
|
262
|
+
test("413 halves the batch instead of dropping it", async () => {
|
|
263
|
+
const collector = await startCollector({
|
|
264
|
+
respond: (req) =>
|
|
265
|
+
req.body.events.length > 1 ? { status: 413, body: {} } : { status: 201, body: { success: 1, failed: 0 } },
|
|
266
|
+
});
|
|
267
|
+
const agent = agentFor(collector.url, { onError: () => {} });
|
|
268
|
+
try {
|
|
269
|
+
for (let i = 0; i < 4; i++) agent.addEvent({ type: "request", path: `/e${i}` });
|
|
270
|
+
await agent.flush();
|
|
271
|
+
assert.equal(agent.queued, 0);
|
|
272
|
+
const accepted = collector.requests.filter((request) => request.body.events.length === 1);
|
|
273
|
+
assert.deepEqual(accepted.map((request) => request.body.events[0].route), ["/e0", "/e1", "/e2", "/e3"]);
|
|
274
|
+
} finally {
|
|
275
|
+
agent.close();
|
|
276
|
+
await collector.close();
|
|
277
|
+
}
|
|
278
|
+
});
|
|
279
|
+
|
|
280
|
+
test("unreachable Midline server: bounded queue, oldest dropped, nothing thrown", async () => {
|
|
281
|
+
const port = await closedPort();
|
|
282
|
+
const { messages, onError } = diagnostics();
|
|
283
|
+
const agent = agentFor(`http://127.0.0.1:${port}`, { onError, maxQueueSize: 3 });
|
|
284
|
+
try {
|
|
285
|
+
for (let i = 0; i < 10; i++) {
|
|
286
|
+
assert.doesNotThrow(() => agent.addEvent({ type: "request", path: `/e${i}` }));
|
|
287
|
+
}
|
|
288
|
+
assert.equal(agent.queued, 3);
|
|
289
|
+
await agent.flush();
|
|
290
|
+
await agent.flush();
|
|
291
|
+
assert.equal(agent.queued, 3);
|
|
292
|
+
assert.equal(messages.filter((message) => /ECONNREFUSED|refused/.test(message)).length, 1, "one line per distinct fault");
|
|
293
|
+
} finally {
|
|
294
|
+
agent.close();
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("timeouts: a server that never answers does not stall delivery", async () => {
|
|
299
|
+
const http = require("http");
|
|
300
|
+
const sockets = new Set();
|
|
301
|
+
const server = http.createServer(() => {});
|
|
302
|
+
server.on("connection", (socket) => sockets.add(socket));
|
|
303
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
304
|
+
const { messages, onError } = diagnostics();
|
|
305
|
+
const agent = agentFor(`http://127.0.0.1:${server.address().port}`, { onError, timeoutMs: 200 });
|
|
306
|
+
try {
|
|
307
|
+
agent.addEvent({ type: "request", path: "/slow" });
|
|
308
|
+
const started = Date.now();
|
|
309
|
+
await agent.flush();
|
|
310
|
+
assert.ok(Date.now() - started < 1500);
|
|
311
|
+
assert.equal(agent.queued, 1);
|
|
312
|
+
assert.match(messages[0], /did not answer within 200ms/);
|
|
313
|
+
} finally {
|
|
314
|
+
agent.close();
|
|
315
|
+
for (const socket of sockets) socket.destroy();
|
|
316
|
+
await new Promise((resolve) => server.close(resolve));
|
|
317
|
+
}
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test("oversized events lose their captured bodies before they are dropped", async () => {
|
|
321
|
+
const collector = await startCollector();
|
|
322
|
+
const agent = agentFor(collector.url, { maxEventBytes: 2048 });
|
|
323
|
+
try {
|
|
324
|
+
agent.addEvent({
|
|
325
|
+
type: "request",
|
|
326
|
+
path: "/big",
|
|
327
|
+
response: { body: "x".repeat(10_000) },
|
|
328
|
+
});
|
|
329
|
+
await agent.flush();
|
|
330
|
+
const [event] = collector.events();
|
|
331
|
+
assert.ok(event, "event still delivered");
|
|
332
|
+
assert.equal(event.payload.response.body, undefined);
|
|
333
|
+
assert.equal(event.payload.response.omitted, "event size limit");
|
|
334
|
+
} finally {
|
|
335
|
+
agent.close();
|
|
336
|
+
await collector.close();
|
|
337
|
+
}
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("static facade keeps working for existing integrations", async () => {
|
|
341
|
+
const collector = await startCollector();
|
|
342
|
+
try {
|
|
343
|
+
const agent = MidlineAgent.init({ apiKey: "ak_static", endpoint: collector.url, flushIntervalMs: 60_000 });
|
|
344
|
+
assert.equal(MidlineAgent.current, agent);
|
|
345
|
+
MidlineAgent.addEvent({ type: "request", path: "/static" });
|
|
346
|
+
await MidlineAgent.flush();
|
|
347
|
+
assert.equal(collector.events()[0].route, "/static");
|
|
348
|
+
await MidlineAgent.shutdown(500);
|
|
349
|
+
assert.equal(agent.active, false);
|
|
350
|
+
} finally {
|
|
351
|
+
await collector.close();
|
|
352
|
+
}
|
|
353
|
+
});
|
|
@@ -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
|
+
});
|
package/test/helpers.js
ADDED
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { execFileSync } = require("child_process");
|
|
4
|
+
const fs = require("fs");
|
|
5
|
+
const http = require("http");
|
|
6
|
+
const https = require("https");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Mints a throwaway CA, a leaf signed by it for localhost/127.0.0.1, a leaf signed
|
|
12
|
+
* by it for the wrong hostname, and a self-signed leaf. Nothing is committed to the
|
|
13
|
+
* repo; the directory is removed when the process exits.
|
|
14
|
+
*/
|
|
15
|
+
function makeCerts() {
|
|
16
|
+
try {
|
|
17
|
+
execFileSync("openssl", ["version"], { stdio: "ignore" });
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "midline-agent-test-"));
|
|
23
|
+
process.on("exit", () => fs.rmSync(dir, { recursive: true, force: true }));
|
|
24
|
+
const run = (args) => execFileSync("openssl", args, { cwd: dir, stdio: "pipe" });
|
|
25
|
+
const read = (name) => fs.readFileSync(path.join(dir, name), "utf8");
|
|
26
|
+
|
|
27
|
+
run(["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", "ca.key", "-out", "ca.pem", "-days", "2", "-subj", "/CN=Midline Test CA"]);
|
|
28
|
+
|
|
29
|
+
const leaf = (name, san) => {
|
|
30
|
+
fs.writeFileSync(path.join(dir, `${name}.ext`), `subjectAltName=${san}\n`);
|
|
31
|
+
run(["req", "-newkey", "rsa:2048", "-nodes", "-keyout", `${name}.key`, "-out", `${name}.csr`, "-subj", `/CN=${name}`]);
|
|
32
|
+
run(["x509", "-req", "-in", `${name}.csr`, "-CA", "ca.pem", "-CAkey", "ca.key", "-CAcreateserial", "-out", `${name}.pem`, "-days", "2", "-extfile", `${name}.ext`]);
|
|
33
|
+
return { key: read(`${name}.key`), cert: read(`${name}.pem`) };
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
run(["req", "-x509", "-newkey", "rsa:2048", "-nodes", "-keyout", "self.key", "-out", "self.pem", "-days", "2", "-subj", "/CN=localhost"]);
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
dir,
|
|
40
|
+
ca: read("ca.pem"),
|
|
41
|
+
caPath: path.join(dir, "ca.pem"),
|
|
42
|
+
trusted: leaf("localhost", "DNS:localhost,IP:127.0.0.1"),
|
|
43
|
+
wrongHost: leaf("wrong", "DNS:wrong.example"),
|
|
44
|
+
selfSigned: { key: read("self.key"), cert: read("self.pem") },
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A fake Midline server that records what it receives and answers with `respond`. */
|
|
49
|
+
async function startCollector({ tls, respond } = {}) {
|
|
50
|
+
const requests = [];
|
|
51
|
+
let responder = respond || (() => ({ status: 201, body: { success: 1, failed: 0 } }));
|
|
52
|
+
|
|
53
|
+
const handler = (req, res) => {
|
|
54
|
+
const chunks = [];
|
|
55
|
+
req.on("data", (chunk) => chunks.push(chunk));
|
|
56
|
+
req.on("end", () => {
|
|
57
|
+
const raw = Buffer.concat(chunks).toString("utf8");
|
|
58
|
+
let body;
|
|
59
|
+
try {
|
|
60
|
+
body = JSON.parse(raw);
|
|
61
|
+
} catch {
|
|
62
|
+
body = raw;
|
|
63
|
+
}
|
|
64
|
+
const record = { method: req.method, url: req.url, headers: req.headers, body };
|
|
65
|
+
requests.push(record);
|
|
66
|
+
const { status, body: responseBody, headers } = responder(record, requests.length);
|
|
67
|
+
res.writeHead(status, { "content-type": "application/json", ...(headers || {}) });
|
|
68
|
+
res.end(responseBody === undefined ? "" : JSON.stringify(responseBody));
|
|
69
|
+
});
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const server = tls ? https.createServer(tls, handler) : http.createServer(handler);
|
|
73
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
74
|
+
const { port } = server.address();
|
|
75
|
+
|
|
76
|
+
return {
|
|
77
|
+
url: `${tls ? "https" : "http"}://${tls ? "localhost" : "127.0.0.1"}:${port}`,
|
|
78
|
+
ipUrl: `${tls ? "https" : "http"}://127.0.0.1:${port}`,
|
|
79
|
+
port,
|
|
80
|
+
requests,
|
|
81
|
+
events: () => requests.flatMap((request) => (request.body && request.body.events) || []),
|
|
82
|
+
setResponder: (fn) => {
|
|
83
|
+
responder = fn;
|
|
84
|
+
},
|
|
85
|
+
close: () =>
|
|
86
|
+
new Promise((resolve) => {
|
|
87
|
+
server.closeAllConnections?.();
|
|
88
|
+
server.close(() => resolve());
|
|
89
|
+
}),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Starts any request listener on an ephemeral loopback port. */
|
|
94
|
+
async function listen(listener, tls) {
|
|
95
|
+
const server = tls ? https.createServer(tls, listener) : http.createServer(listener);
|
|
96
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
97
|
+
return {
|
|
98
|
+
server,
|
|
99
|
+
port: server.address().port,
|
|
100
|
+
close: () =>
|
|
101
|
+
new Promise((resolve) => {
|
|
102
|
+
server.closeAllConnections?.();
|
|
103
|
+
server.close(() => resolve());
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function request(url, { method = "GET", headers = {}, body } = {}) {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const req = http.request(url, { method, headers, agent: false }, (res) => {
|
|
111
|
+
const chunks = [];
|
|
112
|
+
res.on("data", (chunk) => chunks.push(chunk));
|
|
113
|
+
res.on("end", () => {
|
|
114
|
+
const text = Buffer.concat(chunks).toString("utf8");
|
|
115
|
+
let json;
|
|
116
|
+
try {
|
|
117
|
+
json = JSON.parse(text);
|
|
118
|
+
} catch {
|
|
119
|
+
json = undefined;
|
|
120
|
+
}
|
|
121
|
+
resolve({ status: res.statusCode, headers: res.headers, text, json });
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
req.on("error", reject);
|
|
125
|
+
if (body !== undefined) req.write(body);
|
|
126
|
+
req.end();
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** A port that was free a moment ago and has nothing listening on it now. */
|
|
131
|
+
async function closedPort() {
|
|
132
|
+
const { port, close } = await listen(() => {});
|
|
133
|
+
await close();
|
|
134
|
+
return port;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function diagnostics() {
|
|
138
|
+
const messages = [];
|
|
139
|
+
return { messages, onError: (message) => messages.push(message) };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function waitFor(predicate, timeoutMs = 3000) {
|
|
143
|
+
const deadline = Date.now() + timeoutMs;
|
|
144
|
+
while (Date.now() < deadline) {
|
|
145
|
+
if (await predicate()) return true;
|
|
146
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
147
|
+
}
|
|
148
|
+
return false;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
module.exports = { makeCerts, startCollector, listen, request, closedPort, diagnostics, waitFor };
|