midline-agent 0.4.0 → 0.4.1
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 +1 -1
- package/dist/agent.d.ts +9 -1
- package/dist/agent.js +44 -68
- package/dist/config.d.ts +8 -7
- package/dist/config.js +12 -24
- package/dist/socket-transport.d.ts +58 -0
- package/dist/socket-transport.js +157 -0
- package/package.json +5 -1
- package/src/agent.ts +46 -73
- package/src/config.ts +12 -23
- package/src/socket-transport.ts +188 -0
- package/test/agent.test.js +47 -51
- package/test/console.test.js +11 -10
- package/test/helpers.js +54 -1
- package/test/middleware.test.js +5 -5
- package/test/proxy.test.js +4 -4
- package/src/transport.ts +0 -125
package/test/agent.test.js
CHANGED
|
@@ -4,8 +4,8 @@ const test = require("node:test");
|
|
|
4
4
|
const assert = require("node:assert/strict");
|
|
5
5
|
const tls = require("tls");
|
|
6
6
|
const { MidlineAgent } = require("../dist");
|
|
7
|
-
const { ConfigError, loadCa,
|
|
8
|
-
const { makeCerts,
|
|
7
|
+
const { ConfigError, loadCa, resolveEndpointOrigin, trustStore } = require("../dist/config");
|
|
8
|
+
const { makeCerts, startSocketCollector, closedPort, diagnostics } = require("./helpers");
|
|
9
9
|
|
|
10
10
|
const certs = makeCerts();
|
|
11
11
|
const needsOpenssl = certs ? {} : { skip: "openssl not available" };
|
|
@@ -22,27 +22,27 @@ function agentFor(endpoint, extra = {}) {
|
|
|
22
22
|
});
|
|
23
23
|
}
|
|
24
24
|
|
|
25
|
-
test("endpoint:
|
|
26
|
-
assert.equal(
|
|
27
|
-
assert.equal(
|
|
25
|
+
test("endpoint: only the origin matters, an old-style ingest path is tolerated", () => {
|
|
26
|
+
assert.equal(resolveEndpointOrigin("https://api.usemidline.com").href, "https://api.usemidline.com/");
|
|
27
|
+
assert.equal(resolveEndpointOrigin("https://api.usemidline.com/").href, "https://api.usemidline.com/");
|
|
28
28
|
assert.equal(
|
|
29
|
-
|
|
30
|
-
"https://api.usemidline.com/
|
|
29
|
+
resolveEndpointOrigin("https://api.usemidline.com/api/api-monitor/events").href,
|
|
30
|
+
"https://api.usemidline.com/",
|
|
31
31
|
);
|
|
32
32
|
assert.equal(
|
|
33
|
-
|
|
34
|
-
"https://api.usemidline.com/
|
|
33
|
+
resolveEndpointOrigin("https://api.usemidline.com/api/api-monitor/events/batch").href,
|
|
34
|
+
"https://api.usemidline.com/",
|
|
35
35
|
);
|
|
36
|
-
assert.equal(
|
|
37
|
-
assert.equal(
|
|
38
|
-
assert.equal(
|
|
36
|
+
assert.equal(resolveEndpointOrigin("https://gw.internal/midline").href, "https://gw.internal/");
|
|
37
|
+
assert.equal(resolveEndpointOrigin("http://localhost:8000").href, "http://localhost:8000/");
|
|
38
|
+
assert.equal(resolveEndpointOrigin("http://127.0.0.1:8000").href, "http://127.0.0.1:8000/");
|
|
39
39
|
});
|
|
40
40
|
|
|
41
41
|
test("endpoint: plain http to a non-loopback host is refused, so the key never travels in cleartext", () => {
|
|
42
|
-
assert.throws(() =>
|
|
43
|
-
assert.throws(() =>
|
|
44
|
-
assert.throws(() =>
|
|
45
|
-
assert.throws(() =>
|
|
42
|
+
assert.throws(() => resolveEndpointOrigin("http://api.usemidline.com"), ConfigError);
|
|
43
|
+
assert.throws(() => resolveEndpointOrigin("http://10.0.0.5:8000"), /cleartext/);
|
|
44
|
+
assert.throws(() => resolveEndpointOrigin("ftp://api.usemidline.com"), ConfigError);
|
|
45
|
+
assert.throws(() => resolveEndpointOrigin("https://user:pass@api.usemidline.com"), /credentials/);
|
|
46
46
|
|
|
47
47
|
const { messages, onError } = diagnostics();
|
|
48
48
|
const agent = new MidlineAgent({ apiKey: "ak_x", endpoint: "http://api.usemidline.com", onError });
|
|
@@ -73,7 +73,7 @@ test("custom CA: appended to the default trust store, bad input fails loudly", n
|
|
|
73
73
|
});
|
|
74
74
|
|
|
75
75
|
test("TLS: a self-signed Midline server is refused, events stay buffered, the app is unaffected", needsOpenssl, async () => {
|
|
76
|
-
const collector = await
|
|
76
|
+
const collector = await startSocketCollector({ tls: certs.selfSigned });
|
|
77
77
|
const { messages, onError } = diagnostics();
|
|
78
78
|
const agent = agentFor(collector.url, { onError });
|
|
79
79
|
try {
|
|
@@ -93,13 +93,12 @@ test("TLS: a self-signed Midline server is refused, events stay buffered, the ap
|
|
|
93
93
|
});
|
|
94
94
|
|
|
95
95
|
test("TLS: a private CA passed via `ca` is trusted for the Midline server", needsOpenssl, async () => {
|
|
96
|
-
const collector = await
|
|
96
|
+
const collector = await startSocketCollector({ tls: certs.trusted });
|
|
97
97
|
const agent = agentFor(collector.url, { ca: certs.ca });
|
|
98
98
|
try {
|
|
99
99
|
agent.addEvent({ type: "request", path: "/hello", method: "GET", statusCode: 200 });
|
|
100
100
|
await agent.flush();
|
|
101
101
|
assert.equal(collector.requests.length, 1);
|
|
102
|
-
assert.equal(collector.requests[0].url, "/api/api-monitor/events/batch");
|
|
103
102
|
assert.equal(collector.requests[0].headers["x-api-key"], "ak_test_key");
|
|
104
103
|
assert.equal(agent.queued, 0);
|
|
105
104
|
} finally {
|
|
@@ -109,7 +108,7 @@ test("TLS: a private CA passed via `ca` is trusted for the Midline server", need
|
|
|
109
108
|
});
|
|
110
109
|
|
|
111
110
|
test("TLS: a trusted CA still does not excuse a certificate for the wrong hostname", needsOpenssl, async () => {
|
|
112
|
-
const collector = await
|
|
111
|
+
const collector = await startSocketCollector({ tls: certs.wrongHost });
|
|
113
112
|
const { messages, onError } = diagnostics();
|
|
114
113
|
const agent = agentFor(collector.url, { ca: certs.ca, onError });
|
|
115
114
|
try {
|
|
@@ -124,7 +123,7 @@ test("TLS: a trusted CA still does not excuse a certificate for the wrong hostna
|
|
|
124
123
|
});
|
|
125
124
|
|
|
126
125
|
test("MIDLINE_CUSTOM_CA (file path) is honoured", needsOpenssl, async () => {
|
|
127
|
-
const collector = await
|
|
126
|
+
const collector = await startSocketCollector({ tls: certs.trusted });
|
|
128
127
|
process.env.MIDLINE_CUSTOM_CA = certs.caPath;
|
|
129
128
|
const agent = agentFor(collector.url);
|
|
130
129
|
try {
|
|
@@ -139,7 +138,7 @@ test("MIDLINE_CUSTOM_CA (file path) is honoured", needsOpenssl, async () => {
|
|
|
139
138
|
});
|
|
140
139
|
|
|
141
140
|
test("wire format stays inside the long-standing ingest schema", async () => {
|
|
142
|
-
const collector = await
|
|
141
|
+
const collector = await startSocketCollector();
|
|
143
142
|
const agent = agentFor(collector.url, { environment: "test", release: "v1" });
|
|
144
143
|
try {
|
|
145
144
|
agent.addEvent({
|
|
@@ -176,9 +175,9 @@ test("wire format stays inside the long-standing ingest schema", async () => {
|
|
|
176
175
|
}
|
|
177
176
|
});
|
|
178
177
|
|
|
179
|
-
test("
|
|
180
|
-
const collector = await
|
|
181
|
-
respond: () => ({
|
|
178
|
+
test("rate-limited acks are retried with backoff and keep the events", async () => {
|
|
179
|
+
const collector = await startSocketCollector({
|
|
180
|
+
respond: () => ({ ok: false, code: "rate_limited", message: "rate limited", retryAfterMs: 1000 }),
|
|
182
181
|
});
|
|
183
182
|
const { messages, onError } = diagnostics();
|
|
184
183
|
const agent = agentFor(collector.url, { onError });
|
|
@@ -189,7 +188,7 @@ test("5xx and 429 are retried with backoff and keep the events", async () => {
|
|
|
189
188
|
assert.equal(agent.queued, 2);
|
|
190
189
|
assert.equal(collector.requests.length, 1, "one attempt per drain, not one per event");
|
|
191
190
|
|
|
192
|
-
collector.setResponder(() => ({
|
|
191
|
+
collector.setResponder(() => ({ ok: true, accepted: 2, rejected: 0 }));
|
|
193
192
|
await agent.flush();
|
|
194
193
|
assert.equal(agent.queued, 0);
|
|
195
194
|
assert.match(messages.at(-1), /recovered/);
|
|
@@ -199,8 +198,8 @@ test("5xx and 429 are retried with backoff and keep the events", async () => {
|
|
|
199
198
|
}
|
|
200
199
|
});
|
|
201
200
|
|
|
202
|
-
test("
|
|
203
|
-
const collector = await
|
|
201
|
+
test("unauthorized turns the agent off instead of retrying forever", async () => {
|
|
202
|
+
const collector = await startSocketCollector({ respond: () => ({ ok: false, code: "unauthorized", message: "Invalid API key" }) });
|
|
204
203
|
const { onError } = diagnostics();
|
|
205
204
|
const agent = agentFor(collector.url, { onError });
|
|
206
205
|
try {
|
|
@@ -215,8 +214,10 @@ test("401 turns the agent off instead of retrying forever", async () => {
|
|
|
215
214
|
}
|
|
216
215
|
});
|
|
217
216
|
|
|
218
|
-
test("an
|
|
219
|
-
const collector = await
|
|
217
|
+
test("an ack with every event rejected is treated as a rejected key", async () => {
|
|
218
|
+
const collector = await startSocketCollector({
|
|
219
|
+
respond: (record) => ({ ok: true, accepted: 0, rejected: record.body.events.length }),
|
|
220
|
+
});
|
|
220
221
|
const { onError } = diagnostics();
|
|
221
222
|
const agent = agentFor(collector.url, { onError });
|
|
222
223
|
try {
|
|
@@ -229,14 +230,14 @@ test("an older server's 201 with every event failed is treated as a rejected key
|
|
|
229
230
|
}
|
|
230
231
|
});
|
|
231
232
|
|
|
232
|
-
test("one malformed event costs only itself: a
|
|
233
|
-
const collector = await
|
|
234
|
-
respond: (
|
|
235
|
-
const events =
|
|
233
|
+
test("one malformed event costs only itself: a bad_request batch is re-sent one by one", async () => {
|
|
234
|
+
const collector = await startSocketCollector({
|
|
235
|
+
respond: (record) => {
|
|
236
|
+
const events = record.body.events;
|
|
236
237
|
if (events.some((event) => event.route === "/bad")) {
|
|
237
|
-
return {
|
|
238
|
+
return { ok: false, code: "bad_request", message: "route must be shorter" };
|
|
238
239
|
}
|
|
239
|
-
return {
|
|
240
|
+
return { ok: true, accepted: events.length, rejected: 0 };
|
|
240
241
|
},
|
|
241
242
|
});
|
|
242
243
|
const { messages, onError } = diagnostics();
|
|
@@ -259,10 +260,10 @@ test("one malformed event costs only itself: a 400 batch is re-sent one by one",
|
|
|
259
260
|
}
|
|
260
261
|
});
|
|
261
262
|
|
|
262
|
-
test("
|
|
263
|
-
const collector = await
|
|
264
|
-
respond: (
|
|
265
|
-
|
|
263
|
+
test("too_large halves the batch instead of dropping it", async () => {
|
|
264
|
+
const collector = await startSocketCollector({
|
|
265
|
+
respond: (record) =>
|
|
266
|
+
record.body.events.length > 1 ? { ok: false, code: "too_large", message: "too large" } : { ok: true, accepted: 1, rejected: 0 },
|
|
266
267
|
});
|
|
267
268
|
const agent = agentFor(collector.url, { onError: () => {} });
|
|
268
269
|
try {
|
|
@@ -295,14 +296,10 @@ test("unreachable Midline server: bounded queue, oldest dropped, nothing thrown"
|
|
|
295
296
|
}
|
|
296
297
|
});
|
|
297
298
|
|
|
298
|
-
test("timeouts: a server that never
|
|
299
|
-
const
|
|
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));
|
|
299
|
+
test("timeouts: a server that connects but never acks does not stall delivery", async () => {
|
|
300
|
+
const collector = await startSocketCollector({ noAck: true });
|
|
304
301
|
const { messages, onError } = diagnostics();
|
|
305
|
-
const agent = agentFor(
|
|
302
|
+
const agent = agentFor(collector.url, { onError, timeoutMs: 200 });
|
|
306
303
|
try {
|
|
307
304
|
agent.addEvent({ type: "request", path: "/slow" });
|
|
308
305
|
const started = Date.now();
|
|
@@ -312,13 +309,12 @@ test("timeouts: a server that never answers does not stall delivery", async () =
|
|
|
312
309
|
assert.match(messages[0], /did not answer within 200ms/);
|
|
313
310
|
} finally {
|
|
314
311
|
agent.close();
|
|
315
|
-
|
|
316
|
-
await new Promise((resolve) => server.close(resolve));
|
|
312
|
+
await collector.close();
|
|
317
313
|
}
|
|
318
314
|
});
|
|
319
315
|
|
|
320
316
|
test("oversized events lose their captured bodies before they are dropped", async () => {
|
|
321
|
-
const collector = await
|
|
317
|
+
const collector = await startSocketCollector();
|
|
322
318
|
const agent = agentFor(collector.url, { maxEventBytes: 2048 });
|
|
323
319
|
try {
|
|
324
320
|
agent.addEvent({
|
|
@@ -338,7 +334,7 @@ test("oversized events lose their captured bodies before they are dropped", asyn
|
|
|
338
334
|
});
|
|
339
335
|
|
|
340
336
|
test("static facade keeps working for existing integrations", async () => {
|
|
341
|
-
const collector = await
|
|
337
|
+
const collector = await startSocketCollector();
|
|
342
338
|
try {
|
|
343
339
|
const agent = MidlineAgent.init({ apiKey: "ak_static", endpoint: collector.url, flushIntervalMs: 60_000 });
|
|
344
340
|
assert.equal(MidlineAgent.current, agent);
|
package/test/console.test.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
const test = require("node:test");
|
|
4
4
|
const assert = require("node:assert/strict");
|
|
5
5
|
const { MidlineAgent } = require("../dist");
|
|
6
|
-
const {
|
|
6
|
+
const { startSocketCollector, diagnostics } = require("./helpers");
|
|
7
7
|
|
|
8
8
|
function agentFor(endpoint, extra = {}) {
|
|
9
9
|
return new MidlineAgent({
|
|
@@ -47,7 +47,7 @@ const consoleEvents = (collector) => collector.events().filter((event) => event.
|
|
|
47
47
|
|
|
48
48
|
test("console capture: printed lines become console events and the output itself is untouched", async () => {
|
|
49
49
|
const streams = recordStreams();
|
|
50
|
-
const collector = await
|
|
50
|
+
const collector = await startSocketCollector();
|
|
51
51
|
const agent = agentFor(collector.url);
|
|
52
52
|
try {
|
|
53
53
|
const coloured = "\x1b[32m[Nest] 4242 - LOG [NestApplication] Nest application successfully started\x1b[39m\n";
|
|
@@ -95,7 +95,7 @@ test("console capture: printed lines become console events and the output itself
|
|
|
95
95
|
|
|
96
96
|
test("console capture: the agent's own warnings are not captured, and close() puts the streams back", async () => {
|
|
97
97
|
const streams = recordStreams();
|
|
98
|
-
const collector = await
|
|
98
|
+
const collector = await startSocketCollector({ respond: () => ({ ok: false, code: "rate_limited", message: "down" }) });
|
|
99
99
|
// No onError: the agent's diagnostics go to the console, where capture could see them.
|
|
100
100
|
const agent = agentFor(collector.url);
|
|
101
101
|
try {
|
|
@@ -104,7 +104,7 @@ test("console capture: the agent's own warnings are not captured, and close() pu
|
|
|
104
104
|
await agent.flush();
|
|
105
105
|
assert.ok(streams.written.stderr.some((chunk) => chunk.includes("midline:")), "the warning was printed");
|
|
106
106
|
|
|
107
|
-
collector.setResponder((
|
|
107
|
+
collector.setResponder((record) => ({ ok: true, accepted: record.body.events.length, rejected: 0 }));
|
|
108
108
|
await agent.flush();
|
|
109
109
|
const messages = consoleEvents(collector).map((event) => event.payload.message);
|
|
110
110
|
assert.ok(messages.includes("before the outage"));
|
|
@@ -122,16 +122,17 @@ test("console capture: the agent's own warnings are not captured, and close() pu
|
|
|
122
122
|
|
|
123
123
|
test("console capture: a server without console events turns capture off and requests keep flowing", async () => {
|
|
124
124
|
const streams = recordStreams();
|
|
125
|
-
const collector = await
|
|
126
|
-
respond: (
|
|
127
|
-
const events =
|
|
125
|
+
const collector = await startSocketCollector({
|
|
126
|
+
respond: (record) => {
|
|
127
|
+
const events = record.body.events;
|
|
128
128
|
if (events.some((event) => event.eventType === "console")) {
|
|
129
129
|
return {
|
|
130
|
-
|
|
131
|
-
|
|
130
|
+
ok: false,
|
|
131
|
+
code: "bad_request",
|
|
132
|
+
message: "events.1.eventType must be one of the following values: request, error, security, performance, custom",
|
|
132
133
|
};
|
|
133
134
|
}
|
|
134
|
-
return {
|
|
135
|
+
return { ok: true, accepted: events.length, rejected: 0 };
|
|
135
136
|
},
|
|
136
137
|
});
|
|
137
138
|
const { messages, onError } = diagnostics();
|
package/test/helpers.js
CHANGED
|
@@ -90,6 +90,59 @@ async function startCollector({ tls, respond } = {}) {
|
|
|
90
90
|
};
|
|
91
91
|
}
|
|
92
92
|
|
|
93
|
+
/**
|
|
94
|
+
* A fake ingest gateway: a real socket.io server on the `/ingest` namespace,
|
|
95
|
+
* recording what it receives and acking with whatever `respond` returns
|
|
96
|
+
* (defaulting to "everything accepted"). Shaped like `startCollector` so most
|
|
97
|
+
* assertions port over unchanged: `requests`, `events()`, `setResponder()`, `close()`.
|
|
98
|
+
*/
|
|
99
|
+
async function startSocketCollector({ tls, respond, noAck } = {}) {
|
|
100
|
+
const { Server } = require("socket.io");
|
|
101
|
+
const requests = [];
|
|
102
|
+
let responder =
|
|
103
|
+
respond ||
|
|
104
|
+
((record) => ({ ok: true, accepted: (record.body?.events || []).length, rejected: 0 }));
|
|
105
|
+
|
|
106
|
+
const httpServer = tls ? https.createServer(tls) : http.createServer();
|
|
107
|
+
await new Promise((resolve) => httpServer.listen(0, "127.0.0.1", resolve));
|
|
108
|
+
const { port } = httpServer.address();
|
|
109
|
+
|
|
110
|
+
const io = new Server(httpServer, { cors: { origin: "*" } });
|
|
111
|
+
const nsp = io.of("/ingest");
|
|
112
|
+
nsp.on("connection", (socket) => {
|
|
113
|
+
socket.on("ingest", (payload, ack) => {
|
|
114
|
+
const record = {
|
|
115
|
+
// Old HTTP-era assertions read the key off a header; a synthesized one
|
|
116
|
+
// keeps those checks meaningful without rewriting every one of them.
|
|
117
|
+
headers: { ...socket.handshake.headers, "x-api-key": socket.handshake.auth?.apiKey },
|
|
118
|
+
auth: socket.handshake.auth,
|
|
119
|
+
body: payload,
|
|
120
|
+
};
|
|
121
|
+
requests.push(record);
|
|
122
|
+
// Simulates a connected server that never answers, so callers can exercise
|
|
123
|
+
// the SDK's per-emit ack timeout rather than its connect timeout.
|
|
124
|
+
if (noAck) return;
|
|
125
|
+
ack(responder(record, requests.length));
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
url: `${tls ? "https" : "http"}://${tls ? "localhost" : "127.0.0.1"}:${port}`,
|
|
131
|
+
port,
|
|
132
|
+
requests,
|
|
133
|
+
events: () => requests.flatMap((request) => (request.body && request.body.events) || []),
|
|
134
|
+
setResponder: (fn) => {
|
|
135
|
+
responder = fn;
|
|
136
|
+
},
|
|
137
|
+
close: () =>
|
|
138
|
+
new Promise((resolve) => {
|
|
139
|
+
io.close();
|
|
140
|
+
httpServer.closeAllConnections?.();
|
|
141
|
+
httpServer.close(() => resolve());
|
|
142
|
+
}),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
93
146
|
/** Starts any request listener on an ephemeral loopback port. */
|
|
94
147
|
async function listen(listener, tls) {
|
|
95
148
|
const server = tls ? https.createServer(tls, listener) : http.createServer(listener);
|
|
@@ -148,4 +201,4 @@ async function waitFor(predicate, timeoutMs = 3000) {
|
|
|
148
201
|
return false;
|
|
149
202
|
}
|
|
150
203
|
|
|
151
|
-
module.exports = { makeCerts, startCollector, listen, request, closedPort, diagnostics, waitFor };
|
|
204
|
+
module.exports = { makeCerts, startCollector, startSocketCollector, listen, request, closedPort, diagnostics, waitFor };
|
package/test/middleware.test.js
CHANGED
|
@@ -5,10 +5,10 @@ const assert = require("node:assert/strict");
|
|
|
5
5
|
const express = require("express");
|
|
6
6
|
const { MidlineAgent, midlineMiddleware, midlineErrorHandler, getRequestContext } = require("../dist");
|
|
7
7
|
const { REDACTED } = require("../dist/redact");
|
|
8
|
-
const {
|
|
8
|
+
const { startSocketCollector, listen, request, closedPort, waitFor } = require("./helpers");
|
|
9
9
|
|
|
10
10
|
test("express: captured headers, query and bodies are redacted before they leave the process", async () => {
|
|
11
|
-
const collector = await
|
|
11
|
+
const collector = await startSocketCollector();
|
|
12
12
|
const agent = new MidlineAgent({
|
|
13
13
|
apiKey: "ak_mw",
|
|
14
14
|
endpoint: collector.url,
|
|
@@ -74,7 +74,7 @@ test("express: captured headers, query and bodies are redacted before they leave
|
|
|
74
74
|
});
|
|
75
75
|
|
|
76
76
|
test("express: nothing is captured beyond method/path/status unless asked", async () => {
|
|
77
|
-
const collector = await
|
|
77
|
+
const collector = await startSocketCollector();
|
|
78
78
|
const agent = new MidlineAgent({ apiKey: "ak_mw", endpoint: collector.url, flushIntervalMs: 60_000 });
|
|
79
79
|
const app = express();
|
|
80
80
|
app.use(midlineMiddleware({ agent }));
|
|
@@ -100,7 +100,7 @@ test("express: nothing is captured beyond method/path/status unless asked", asyn
|
|
|
100
100
|
});
|
|
101
101
|
|
|
102
102
|
test("express: errors are recorded and still reach the app's own handler", async () => {
|
|
103
|
-
const collector = await
|
|
103
|
+
const collector = await startSocketCollector();
|
|
104
104
|
const agent = new MidlineAgent({ apiKey: "ak_mw", endpoint: collector.url, flushIntervalMs: 60_000 });
|
|
105
105
|
const app = express();
|
|
106
106
|
app.use(midlineMiddleware({ agent }));
|
|
@@ -154,7 +154,7 @@ test("the host app is unaffected when the Midline server is unreachable or the a
|
|
|
154
154
|
});
|
|
155
155
|
|
|
156
156
|
test("plain node http servers work with the same middleware", async () => {
|
|
157
|
-
const collector = await
|
|
157
|
+
const collector = await startSocketCollector();
|
|
158
158
|
const agent = new MidlineAgent({ apiKey: "ak_mw", endpoint: collector.url, flushIntervalMs: 60_000 });
|
|
159
159
|
const middleware = midlineMiddleware({ agent });
|
|
160
160
|
const server = await listen((req, res) => {
|
package/test/proxy.test.js
CHANGED
|
@@ -5,7 +5,7 @@ const assert = require("node:assert/strict");
|
|
|
5
5
|
const net = require("net");
|
|
6
6
|
const { MidlineAgent, createMidlineProxy, startMidlineProxy, ConfigError } = require("../dist");
|
|
7
7
|
const { REDACTED } = require("../dist/redact");
|
|
8
|
-
const { makeCerts,
|
|
8
|
+
const { makeCerts, startSocketCollector, listen, request, closedPort, waitFor } = require("./helpers");
|
|
9
9
|
|
|
10
10
|
const certs = makeCerts();
|
|
11
11
|
const needsOpenssl = certs ? {} : { skip: "openssl not available" };
|
|
@@ -27,7 +27,7 @@ function echo(req, res) {
|
|
|
27
27
|
}
|
|
28
28
|
|
|
29
29
|
async function setup({ target, collectorOptions, agentOptions, proxyOptions } = {}) {
|
|
30
|
-
const collector = await
|
|
30
|
+
const collector = await startSocketCollector(collectorOptions);
|
|
31
31
|
const agent = new MidlineAgent({ apiKey: "ak_proxy", endpoint: collector.url, flushIntervalMs: 60_000, ...agentOptions });
|
|
32
32
|
const server = await startMidlineProxy({ target, port: 0, agent, onError: () => {}, ...proxyOptions });
|
|
33
33
|
const base = `http://127.0.0.1:${server.address().port}`;
|
|
@@ -93,7 +93,7 @@ test("forwards to the destination and reports the exchange to Midline separately
|
|
|
93
93
|
await waitFor(() => ctx.agent.queued === 1);
|
|
94
94
|
await ctx.agent.flush();
|
|
95
95
|
const [event] = ctx.collector.events();
|
|
96
|
-
assert.equal(ctx.collector.requests
|
|
96
|
+
assert.equal(ctx.collector.requests.length, 1, "events go to Midline, not the destination");
|
|
97
97
|
assert.equal(event.route, "/v1/items");
|
|
98
98
|
assert.equal(event.metadata.integrationType, "proxy");
|
|
99
99
|
assert.equal(event.metadata.requestId, "rid-7");
|
|
@@ -254,7 +254,7 @@ test("https destination with a private CA: trusted only via targetCa, never by d
|
|
|
254
254
|
|
|
255
255
|
test("the destination CA and the Midline CA are independent", needsOpenssl, async () => {
|
|
256
256
|
const destination = await listen(echo, certs.trusted);
|
|
257
|
-
const collector = await
|
|
257
|
+
const collector = await startSocketCollector({ tls: certs.selfSigned });
|
|
258
258
|
const agent = new MidlineAgent({ apiKey: "ak_proxy", endpoint: collector.url, flushIntervalMs: 60_000, onError: () => {} });
|
|
259
259
|
const server = await startMidlineProxy({ target: `https://localhost:${destination.port}`, targetCa: certs.ca, port: 0, agent });
|
|
260
260
|
try {
|
package/src/transport.ts
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
import * as http from "http";
|
|
2
|
-
import * as https from "https";
|
|
3
|
-
|
|
4
|
-
export interface PostResult {
|
|
5
|
-
status: number;
|
|
6
|
-
headers: http.IncomingHttpHeaders;
|
|
7
|
-
body: string;
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export interface TransportOptions {
|
|
11
|
-
/** Full trust store for https, or undefined for Node's default. Verification is never disabled. */
|
|
12
|
-
ca?: Array<string | Buffer>;
|
|
13
|
-
connectTimeoutMs: number;
|
|
14
|
-
timeoutMs: number;
|
|
15
|
-
userAgent: string;
|
|
16
|
-
/** Response bodies are only read for diagnostics; anything past this is discarded. */
|
|
17
|
-
maxResponseBytes?: number;
|
|
18
|
-
}
|
|
19
|
-
|
|
20
|
-
/** A transport failure with a stable `code`, including the two timeouts Node doesn't name. */
|
|
21
|
-
export class TransportError extends Error {
|
|
22
|
-
constructor(message: string, readonly code: string) {
|
|
23
|
-
super(message);
|
|
24
|
-
this.name = "TransportError";
|
|
25
|
-
}
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Minimal JSON POST over Node's own http/https.
|
|
30
|
-
*
|
|
31
|
-
* Node's modules rather than fetch because this needs things fetch doesn't expose
|
|
32
|
-
* portably: a per-endpoint CA that extends rather than replaces the trust store, a
|
|
33
|
-
* connect timeout separate from the request deadline, and sockets that don't keep
|
|
34
|
-
* the host process alive.
|
|
35
|
-
*/
|
|
36
|
-
export class Transport {
|
|
37
|
-
private readonly agent: http.Agent;
|
|
38
|
-
|
|
39
|
-
constructor(origin: URL, private readonly options: TransportOptions) {
|
|
40
|
-
this.agent = origin.protocol === "https:"
|
|
41
|
-
? new https.Agent({ keepAlive: true, maxSockets: 4, ca: options.ca })
|
|
42
|
-
: new http.Agent({ keepAlive: true, maxSockets: 4 });
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
post(url: URL, body: string, headers: Record<string, string>, keepProcessAlive: boolean): Promise<PostResult> {
|
|
46
|
-
const { connectTimeoutMs, timeoutMs } = this.options;
|
|
47
|
-
const maxResponseBytes = this.options.maxResponseBytes ?? 64 * 1024;
|
|
48
|
-
const isHttps = url.protocol === "https:";
|
|
49
|
-
|
|
50
|
-
return new Promise<PostResult>((resolve, reject) => {
|
|
51
|
-
let settled = false;
|
|
52
|
-
let connectTimer: NodeJS.Timeout | undefined;
|
|
53
|
-
|
|
54
|
-
const settle = (fn: () => void) => {
|
|
55
|
-
if (settled) return;
|
|
56
|
-
settled = true;
|
|
57
|
-
clearTimeout(deadline);
|
|
58
|
-
if (connectTimer) clearTimeout(connectTimer);
|
|
59
|
-
fn();
|
|
60
|
-
};
|
|
61
|
-
|
|
62
|
-
const request = (isHttps ? https : http).request(url, {
|
|
63
|
-
method: "POST",
|
|
64
|
-
agent: this.agent,
|
|
65
|
-
headers: {
|
|
66
|
-
...headers,
|
|
67
|
-
"content-type": "application/json",
|
|
68
|
-
"content-length": String(Buffer.byteLength(body)),
|
|
69
|
-
"user-agent": this.options.userAgent,
|
|
70
|
-
},
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
const deadline = setTimeout(() => {
|
|
74
|
-
request.destroy(new TransportError(`no response within ${timeoutMs}ms`, "ETIMEDOUT"));
|
|
75
|
-
}, timeoutMs);
|
|
76
|
-
|
|
77
|
-
request.on("socket", (socket) => {
|
|
78
|
-
if (!keepProcessAlive) {
|
|
79
|
-
socket.unref();
|
|
80
|
-
} else {
|
|
81
|
-
socket.ref();
|
|
82
|
-
}
|
|
83
|
-
// A pooled keep-alive socket is already connected; only time fresh ones.
|
|
84
|
-
// The overall deadline still covers a handshake that stalls after connect.
|
|
85
|
-
if ((socket as any).connecting) {
|
|
86
|
-
connectTimer = setTimeout(() => {
|
|
87
|
-
request.destroy(new TransportError(`connection not established within ${connectTimeoutMs}ms`, "ECONNECT_TIMEOUT"));
|
|
88
|
-
}, connectTimeoutMs);
|
|
89
|
-
socket.once(isHttps ? "secureConnect" : "connect", () => clearTimeout(connectTimer));
|
|
90
|
-
}
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
request.on("response", (response) => {
|
|
94
|
-
const chunks: Buffer[] = [];
|
|
95
|
-
let received = 0;
|
|
96
|
-
response.on("data", (chunk: Buffer) => {
|
|
97
|
-
if (received < maxResponseBytes) {
|
|
98
|
-
chunks.push(chunk.subarray(0, maxResponseBytes - received));
|
|
99
|
-
}
|
|
100
|
-
received += chunk.length;
|
|
101
|
-
});
|
|
102
|
-
response.on("end", () =>
|
|
103
|
-
settle(() =>
|
|
104
|
-
resolve({
|
|
105
|
-
status: response.statusCode ?? 0,
|
|
106
|
-
headers: response.headers,
|
|
107
|
-
body: Buffer.concat(chunks).toString("utf8"),
|
|
108
|
-
}),
|
|
109
|
-
),
|
|
110
|
-
);
|
|
111
|
-
response.on("error", (err) => settle(() => reject(err)));
|
|
112
|
-
response.on("aborted", () =>
|
|
113
|
-
settle(() => reject(new TransportError("response aborted by the server", "ECONNRESET"))),
|
|
114
|
-
);
|
|
115
|
-
});
|
|
116
|
-
|
|
117
|
-
request.on("error", (err) => settle(() => reject(err)));
|
|
118
|
-
request.end(body);
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
destroy(): void {
|
|
123
|
-
this.agent.destroy();
|
|
124
|
-
}
|
|
125
|
-
}
|