midline-agent 0.3.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.
Files changed (56) hide show
  1. package/README.md +90 -5
  2. package/browser/package.json +8 -0
  3. package/dist/agent.d.ts +9 -1
  4. package/dist/agent.js +44 -68
  5. package/dist/browser/client.d.ts +59 -0
  6. package/dist/browser/client.js +608 -0
  7. package/dist/browser/index.d.ts +34 -0
  8. package/dist/browser/index.js +65 -0
  9. package/dist/browser/instrument.d.ts +39 -0
  10. package/dist/browser/instrument.js +217 -0
  11. package/dist/browser/transport.d.ts +43 -0
  12. package/dist/browser/transport.js +168 -0
  13. package/dist/browser/types.d.ts +94 -0
  14. package/dist/browser/types.js +2 -0
  15. package/dist/browser/version.d.ts +2 -0
  16. package/dist/browser/version.js +5 -0
  17. package/dist/browser/vitals.d.ts +16 -0
  18. package/dist/browser/vitals.js +135 -0
  19. package/dist/cli.js +0 -0
  20. package/dist/config.d.ts +8 -7
  21. package/dist/config.js +12 -24
  22. package/dist/esm/browser/client.js +601 -0
  23. package/dist/esm/browser/index.js +52 -0
  24. package/dist/esm/browser/instrument.js +210 -0
  25. package/dist/esm/browser/transport.js +164 -0
  26. package/dist/esm/browser/types.js +1 -0
  27. package/dist/esm/browser/version.js +2 -0
  28. package/dist/esm/browser/vitals.js +132 -0
  29. package/dist/esm/package.json +1 -0
  30. package/dist/esm/redact.js +224 -0
  31. package/dist/esm/types.js +1 -0
  32. package/dist/redact.d.ts +3 -0
  33. package/dist/redact.js +12 -6
  34. package/dist/socket-transport.d.ts +58 -0
  35. package/dist/socket-transport.js +157 -0
  36. package/package.json +31 -4
  37. package/scripts/mark-esm.js +6 -0
  38. package/src/agent.ts +46 -73
  39. package/src/browser/client.ts +686 -0
  40. package/src/browser/index.ts +74 -0
  41. package/src/browser/instrument.ts +275 -0
  42. package/src/browser/transport.ts +184 -0
  43. package/src/browser/types.ts +105 -0
  44. package/src/browser/version.ts +2 -0
  45. package/src/browser/vitals.ts +149 -0
  46. package/src/config.ts +12 -23
  47. package/src/redact.ts +12 -6
  48. package/src/socket-transport.ts +188 -0
  49. package/test/agent.test.js +47 -51
  50. package/test/browser.test.js +328 -0
  51. package/test/console.test.js +11 -10
  52. package/test/helpers.js +54 -1
  53. package/test/middleware.test.js +5 -5
  54. package/test/proxy.test.js +4 -4
  55. package/tsconfig.esm.json +14 -0
  56. package/src/transport.ts +0 -125
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 };
@@ -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 { startCollector, listen, request, closedPort, waitFor } = require("./helpers");
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 startCollector();
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 startCollector();
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 startCollector();
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 startCollector();
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) => {
@@ -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, startCollector, listen, request, closedPort, waitFor } = require("./helpers");
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 startCollector(collectorOptions);
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[0].url, "/api/api-monitor/events/batch", "events go to Midline, not the destination");
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 startCollector({ tls: certs.selfSigned });
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 {
@@ -0,0 +1,14 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "compilerOptions": {
4
+ "module": "ES2020",
5
+ "moduleResolution": "node",
6
+ "outDir": "dist/esm",
7
+ "declaration": false
8
+ },
9
+ "include": [
10
+ "src/browser",
11
+ "src/redact.ts",
12
+ "src/types.ts"
13
+ ]
14
+ }
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
- }