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
@@ -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, resolveIngestUrl, trustStore } = require("../dist/config");
8
- const { makeCerts, startCollector, closedPort, diagnostics } = require("./helpers");
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: 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");
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
- resolveIngestUrl("https://api.usemidline.com/api/api-monitor/events").href,
30
- "https://api.usemidline.com/api/api-monitor/events",
29
+ resolveEndpointOrigin("https://api.usemidline.com/api/api-monitor/events").href,
30
+ "https://api.usemidline.com/",
31
31
  );
32
32
  assert.equal(
33
- resolveIngestUrl("https://api.usemidline.com/api/api-monitor/events/batch").href,
34
- "https://api.usemidline.com/api/api-monitor/events",
33
+ resolveEndpointOrigin("https://api.usemidline.com/api/api-monitor/events/batch").href,
34
+ "https://api.usemidline.com/",
35
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");
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(() => 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/);
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 startCollector({ tls: certs.selfSigned });
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 startCollector({ tls: certs.trusted });
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 startCollector({ tls: certs.wrongHost });
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 startCollector({ tls: certs.trusted });
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 startCollector();
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("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" } }),
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(() => ({ status: 201, body: { success: 2, failed: 0 } }));
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("401 turns the agent off instead of retrying forever", async () => {
203
- const collector = await startCollector({ respond: () => ({ status: 401, body: { message: "Invalid API key" } }) });
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 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 } }) });
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 400 batch is re-sent one by one", async () => {
233
- const collector = await startCollector({
234
- respond: (req) => {
235
- const events = req.body.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 { status: 400, body: { message: ["route must be shorter"] } };
238
+ return { ok: false, code: "bad_request", message: "route must be shorter" };
238
239
  }
239
- return { status: 201, body: { success: events.length, failed: 0 } };
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("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 } },
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 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));
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(`http://127.0.0.1:${server.address().port}`, { onError, timeoutMs: 200 });
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
- for (const socket of sockets) socket.destroy();
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 startCollector();
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 startCollector();
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);
@@ -0,0 +1,328 @@
1
+ "use strict";
2
+
3
+ const test = require("node:test");
4
+ const assert = require("node:assert/strict");
5
+ const Midline = require("../dist/browser");
6
+ const { BROWSER_SDK_VERSION, resolveBatchUrl } = Midline;
7
+
8
+ const PAGE = "https://app.example.com/checkout";
9
+ const INGEST = "https://api.usemidline.com/api/api-monitor/events/batch";
10
+ const KEY = "pk_0123456789abcdef";
11
+
12
+ /**
13
+ * A window made of Node's own web globals (EventTarget, fetch types, performance),
14
+ * with a fetch that answers from a table instead of the network.
15
+ */
16
+ function fakeWindow({ ingestStatus = 201, ingestBody = '{"success":1}' } = {}) {
17
+ const win = new EventTarget();
18
+ const location = new URL(PAGE);
19
+ const calls = [];
20
+ const warnings = [];
21
+
22
+ Object.assign(win, {
23
+ location: {
24
+ get href() { return location.href; },
25
+ get origin() { return location.origin; },
26
+ get pathname() { return location.pathname; },
27
+ get hash() { return location.hash; },
28
+ get search() { return location.search; },
29
+ },
30
+ document: Object.assign(new EventTarget(), { visibilityState: "visible" }),
31
+ navigator: { userAgent: "node-test" },
32
+ console: {
33
+ warn: (...args) => warnings.push(args.join(" ")),
34
+ log: () => {},
35
+ error: () => {},
36
+ info: () => {},
37
+ debug: () => {},
38
+ },
39
+ history: {
40
+ pushState(_state, _title, url) { location.href = new URL(url, location.href).href; },
41
+ replaceState(_state, _title, url) { location.href = new URL(url, location.href).href; },
42
+ },
43
+ performance,
44
+ });
45
+
46
+ win.fetch = async (input, init) => {
47
+ const url = String(input instanceof Request ? input.url : input);
48
+ calls.push({ url, init });
49
+ if (url === INGEST) return new Response(ingestBody, { status: ingestStatus });
50
+ if (url.endsWith("/offline")) throw new TypeError("Failed to fetch");
51
+ if (url.endsWith("/aborted")) throw Object.assign(new Error("aborted"), { name: "AbortError" });
52
+ const status = Number(new URL(url, PAGE).searchParams.get("status") || 200);
53
+ return new Response(status === 204 ? null : "{}", { status });
54
+ };
55
+
56
+ globalThis.window = win;
57
+ return {
58
+ win,
59
+ calls,
60
+ warnings,
61
+ sent: () =>
62
+ calls
63
+ .filter((call) => call.url === INGEST)
64
+ .flatMap((call) => JSON.parse(call.init.body).events),
65
+ };
66
+ }
67
+
68
+ function uncaught(win, error, extra = {}) {
69
+ const event = new Event("error");
70
+ for (const [key, value] of Object.entries({ error, message: error?.message, ...extra })) {
71
+ Object.defineProperty(event, key, { value });
72
+ }
73
+ win.dispatchEvent(event);
74
+ }
75
+
76
+ async function withSdk(config, run, windowOptions) {
77
+ const env = fakeWindow(windowOptions);
78
+ Midline.init({ apiKey: KEY, flushIntervalMs: 60_000, captureWebVitals: false, ...config });
79
+ try {
80
+ await run(env);
81
+ } finally {
82
+ await Midline.close();
83
+ delete globalThis.window;
84
+ }
85
+ }
86
+
87
+ test("browser: the version constant matches package.json", () => {
88
+ assert.equal(BROWSER_SDK_VERSION, require("../package.json").version);
89
+ });
90
+
91
+ test("browser: endpoint accepts a base or ingest URL, https only except localhost", () => {
92
+ assert.equal(resolveBatchUrl(), INGEST);
93
+ assert.equal(resolveBatchUrl("https://midline.internal/"), "https://midline.internal/api/api-monitor/events/batch");
94
+ assert.equal(resolveBatchUrl("https://midline.internal/api/api-monitor/events"), "https://midline.internal/api/api-monitor/events/batch");
95
+ assert.equal(resolveBatchUrl("http://localhost:8076"), "http://localhost:8076/api/api-monitor/events/batch");
96
+ assert.throws(() => resolveBatchUrl("http://midline.internal"), /https/);
97
+ assert.throws(() => resolveBatchUrl("https://user:pw@midline.internal"), /credentials/);
98
+ });
99
+
100
+ test("browser: does nothing without a window, as during server-side rendering", async () => {
101
+ delete globalThis.window;
102
+ assert.doesNotThrow(() => Midline.init({ apiKey: KEY }));
103
+ assert.doesNotThrow(() => Midline.captureException(new Error("ssr")));
104
+ await Midline.close();
105
+ });
106
+
107
+ test("browser: refuses a server key and leaves the page untouched", async () => {
108
+ const env = fakeWindow();
109
+ const originalFetch = env.win.fetch;
110
+ Midline.init({ apiKey: "ak_secret_server_key" });
111
+ assert.equal(env.win.fetch, originalFetch);
112
+ assert.match(env.warnings.join("\n"), /server key \(ak_/);
113
+ Midline.captureException(new Error("nope"));
114
+ await Midline.flush();
115
+ assert.equal(env.calls.length, 0);
116
+ await Midline.close();
117
+ delete globalThis.window;
118
+ });
119
+
120
+ test("browser: uncaught errors and rejections become redacted error events", async () => {
121
+ await withSdk({ service: "checkout-web", release: "1.2.3" }, async ({ win, calls, sent }) => {
122
+ uncaught(win, new TypeError("cannot read password=hunter2 of undefined"), {
123
+ filename: "https://app.example.com/assets/app.js?token=abc",
124
+ lineno: 12,
125
+ colno: 7,
126
+ });
127
+ const rejection = new Event("unhandledrejection");
128
+ Object.defineProperty(rejection, "reason", { value: "plain string reason" });
129
+ win.dispatchEvent(rejection);
130
+ await Midline.flush();
131
+
132
+ const [error, rejected] = sent();
133
+ assert.equal(error.eventType, "error");
134
+ assert.equal(error.route, "/checkout");
135
+ assert.equal(error.payload.type, "TypeError");
136
+ assert.equal(error.payload.mechanism, "onerror");
137
+ assert.equal(error.payload.error, "cannot read password=[REDACTED] of undefined");
138
+ assert.equal(error.payload.context.filename, "https://app.example.com/assets/app.js");
139
+ assert.equal(error.service, "checkout-web");
140
+ assert.equal(error.release, "1.2.3");
141
+ assert.equal(error.metadata.sdk, "midline-agent/browser");
142
+ assert.equal(error.metadata.runtime, "browser");
143
+ assert.match(error.traceId, /^[a-f0-9]{32}$/);
144
+ assert.equal(error.statusCode, undefined, "a browser error is not an HTTP 500");
145
+
146
+ assert.equal(rejected.payload.error, "plain string reason");
147
+ assert.equal(rejected.payload.mechanism, "unhandledrejection");
148
+
149
+ const delivery = calls.find((call) => call.url === INGEST);
150
+ assert.equal(delivery.init.headers["X-API-Key"], KEY);
151
+ assert.equal(delivery.init.credentials, "omit");
152
+ assert.doesNotMatch(delivery.init.body, /hunter2/);
153
+ });
154
+ });
155
+
156
+ test("browser: the same error twice in quick succession is sent once; cross-origin 'Script error.' is skipped", async () => {
157
+ await withSdk({}, async ({ win, sent }) => {
158
+ const boom = new Error("render loop");
159
+ uncaught(win, boom);
160
+ uncaught(win, boom);
161
+ uncaught(win, undefined, { message: "Script error." });
162
+ await Midline.flush();
163
+ assert.equal(sent().length, 1);
164
+ });
165
+ });
166
+
167
+ test("browser: fetch gets a traceparent on same-origin calls only, and failures become events", async () => {
168
+ await withSdk({}, async ({ win, calls, sent }) => {
169
+ await win.fetch("/api/charges?status=500&card=4242", { method: "post" });
170
+ await win.fetch("https://third-party.example/pixel?status=200");
171
+ await win.fetch(new Request("https://app.example.com/api/cart?status=200", { headers: { "x-custom": "kept" } }));
172
+ await assert.rejects(win.fetch("/api/offline"));
173
+ await assert.rejects(win.fetch("/api/aborted"));
174
+ await Midline.flush();
175
+
176
+ const sameOrigin = calls.find((call) => call.url.includes("/api/charges"));
177
+ const traceparent = sameOrigin.init.headers.get("traceparent");
178
+ assert.match(traceparent, /^00-[a-f0-9]{32}-[a-f0-9]{16}-01$/);
179
+ assert.equal(calls.find((call) => call.url.includes("third-party")).init, undefined, "cross-origin calls are untouched");
180
+ const fromRequest = calls.find((call) => call.url.includes("/api/cart"));
181
+ assert.equal(fromRequest.init.headers.get("x-custom"), "kept", "a Request's own headers survive");
182
+
183
+ const events = sent();
184
+ assert.equal(events.length, 2, "only the 500 and the network failure are events; the 200s and the abort are not");
185
+ const [failed, offline] = events;
186
+ assert.equal(failed.eventType, "request");
187
+ assert.equal(failed.route, "/api/charges");
188
+ assert.equal(failed.method, "POST");
189
+ assert.equal(failed.statusCode, 500);
190
+ assert.equal(failed.severity, "high");
191
+ assert.equal(failed.traceId, traceparent.split("-")[1]);
192
+ assert.equal(failed.spanId, traceparent.split("-")[2]);
193
+ assert.equal(failed.payload.request.query.card, "4242");
194
+
195
+ assert.equal(offline.eventType, "error");
196
+ assert.equal(offline.payload.code, "NETWORK_ERROR");
197
+ assert.equal(offline.statusCode, undefined);
198
+ assert.ok(offline.payload.breadcrumbs.some((crumb) => crumb.message === "POST /api/charges 500"));
199
+
200
+ assert.ok(!events.some((event) => event.route.includes("api-monitor")), "Midline's own delivery is never recorded");
201
+ });
202
+ });
203
+
204
+ test("browser: captureRequests 'all' records successful calls too", async () => {
205
+ await withSdk({ captureRequests: "all" }, async ({ win, sent }) => {
206
+ await win.fetch("/api/ok?status=204");
207
+ await Midline.flush();
208
+ assert.equal(sent()[0].statusCode, 204);
209
+ assert.equal(sent()[0].severity, "low");
210
+ });
211
+ });
212
+
213
+ test("browser: a refused key switches the SDK off after one warning", async () => {
214
+ await withSdk(
215
+ {},
216
+ async ({ win, warnings, sent, calls }) => {
217
+ Midline.captureMessage("first");
218
+ await Midline.flush();
219
+ assert.match(warnings.join("\n"), /HTTP 403: This browser key isn't allowed from this origin/);
220
+ assert.equal(sent().length, 1);
221
+
222
+ Midline.captureMessage("second");
223
+ uncaught(win, new Error("after stop"));
224
+ await Midline.flush();
225
+ assert.equal(calls.filter((call) => call.url === INGEST).length, 1);
226
+ },
227
+ { ingestStatus: 403, ingestBody: JSON.stringify({ message: "This browser key isn't allowed from this origin." }) },
228
+ );
229
+ });
230
+
231
+ test("browser: server errors are retried rather than dropped", async () => {
232
+ await withSdk(
233
+ {},
234
+ async ({ calls }) => {
235
+ Midline.captureMessage("kept");
236
+ await Midline.flush();
237
+ assert.equal(calls.filter((call) => call.url === INGEST).length, 1);
238
+ // Backing off: an immediate second flush does not hammer the server.
239
+ await Midline.flush();
240
+ assert.equal(calls.filter((call) => call.url === INGEST).length, 1);
241
+ },
242
+ { ingestStatus: 503, ingestBody: "" },
243
+ );
244
+ });
245
+
246
+ test("browser: beforeSend can drop events and can't add fields the server would reject", async () => {
247
+ await withSdk(
248
+ {
249
+ beforeSend(event) {
250
+ if (event.payload?.message === "drop me") return null;
251
+ return { ...event, organisationId: "someone-else", payload: { ...event.payload, extra: "ok" } };
252
+ },
253
+ },
254
+ async ({ sent }) => {
255
+ Midline.captureMessage("drop me");
256
+ Midline.captureMessage("keep me", "medium");
257
+ await Midline.flush();
258
+ const events = sent();
259
+ assert.equal(events.length, 1);
260
+ assert.equal(events[0].organisationId, undefined);
261
+ assert.equal(events[0].payload.extra, "ok");
262
+ assert.equal(events[0].severity, "medium");
263
+ },
264
+ );
265
+ });
266
+
267
+ test("browser: user, tags and a rate limit", async () => {
268
+ await withSdk({ maxEventsPerMinute: 2 }, async ({ sent }) => {
269
+ Midline.setUser({ id: 42, email: "not kept" });
270
+ Midline.setTag("plan", "pro");
271
+ for (let i = 0; i < 5; i++) Midline.captureMessage(`message ${i}`);
272
+ await Midline.flush();
273
+ const events = sent();
274
+ assert.equal(events.length, 2);
275
+ assert.deepEqual(events[0].metadata.user, { id: "42" });
276
+ assert.deepEqual(events[0].metadata.tags, { plan: "pro" });
277
+ });
278
+ });
279
+
280
+ test("browser: console capture is opt-in and never loops", async () => {
281
+ await withSdk({ captureConsole: true }, async ({ win, sent }) => {
282
+ win.console.error("payment failed", { token: "secret-token", amount: 5 });
283
+ win.console.info("not captured");
284
+ await Midline.flush();
285
+ const [line] = sent();
286
+ assert.equal(sent().length, 1);
287
+ assert.equal(line.eventType, "console");
288
+ assert.equal(line.severity, "high");
289
+ assert.equal(line.metadata.level, "error");
290
+ assert.doesNotMatch(line.payload.message, /secret-token/);
291
+ });
292
+ });
293
+
294
+ test("browser: navigation starts a new trace; close restores everything it wrapped", async () => {
295
+ const env = fakeWindow();
296
+ const { win } = env;
297
+ const originalFetch = win.fetch;
298
+ const originalPush = win.history.pushState;
299
+ const originalError = win.console.error;
300
+
301
+ Midline.init({ apiKey: KEY, flushIntervalMs: 60_000, captureWebVitals: false, captureConsole: true });
302
+ assert.notEqual(win.fetch, originalFetch);
303
+
304
+ Midline.captureMessage("before");
305
+ win.history.pushState({}, "", "/orders/7");
306
+ Midline.captureMessage("after");
307
+ await Midline.flush();
308
+ const [before, after] = env.sent();
309
+ assert.equal(after.route, "/orders/7");
310
+ assert.notEqual(before.traceId, after.traceId);
311
+
312
+ await Midline.close();
313
+ assert.equal(win.fetch, originalFetch);
314
+ assert.equal(win.history.pushState, originalPush);
315
+ assert.equal(win.console.error, originalError);
316
+ delete globalThis.window;
317
+ });
318
+
319
+ test("browser: pending events go out with keepalive when the page is hidden", async () => {
320
+ await withSdk({}, async ({ win, calls }) => {
321
+ Midline.captureMessage("leaving");
322
+ win.document.visibilityState = "hidden";
323
+ win.document.dispatchEvent(new Event("visibilitychange"));
324
+ await new Promise((resolve) => setImmediate(resolve));
325
+ const delivery = calls.find((call) => call.url === INGEST);
326
+ assert.equal(delivery.init.keepalive, true);
327
+ });
328
+ });
@@ -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 { startCollector, diagnostics } = require("./helpers");
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 startCollector();
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 startCollector({ respond: () => ({ status: 503, body: { message: "down" } }) });
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((req) => ({ status: 201, body: { success: req.body.events.length, failed: 0 } }));
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 startCollector({
126
- respond: (req) => {
127
- const events = req.body.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
- status: 400,
131
- body: { message: ["events.1.eventType must be one of the following values: request, error, security, performance, custom"] },
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 { status: 201, body: { success: events.length, failed: 0 } };
135
+ return { ok: true, accepted: events.length, rejected: 0 };
135
136
  },
136
137
  });
137
138
  const { messages, onError } = diagnostics();