midline-agent 0.2.0 → 0.4.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 +115 -2
- package/browser/package.json +8 -0
- package/dist/agent.d.ts +14 -1
- package/dist/agent.js +106 -20
- package/dist/browser/client.d.ts +59 -0
- package/dist/browser/client.js +608 -0
- package/dist/browser/index.d.ts +34 -0
- package/dist/browser/index.js +65 -0
- package/dist/browser/instrument.d.ts +39 -0
- package/dist/browser/instrument.js +217 -0
- package/dist/browser/transport.d.ts +43 -0
- package/dist/browser/transport.js +168 -0
- package/dist/browser/types.d.ts +94 -0
- package/dist/browser/types.js +2 -0
- package/dist/browser/version.d.ts +2 -0
- package/dist/browser/version.js +5 -0
- package/dist/browser/vitals.d.ts +16 -0
- package/dist/browser/vitals.js +135 -0
- package/dist/cli.js +0 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -0
- package/dist/console.d.ts +46 -0
- package/dist/console.js +167 -0
- package/dist/esm/browser/client.js +601 -0
- package/dist/esm/browser/index.js +52 -0
- package/dist/esm/browser/instrument.js +210 -0
- package/dist/esm/browser/transport.js +164 -0
- package/dist/esm/browser/types.js +1 -0
- package/dist/esm/browser/version.js +2 -0
- package/dist/esm/browser/vitals.js +132 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/redact.js +224 -0
- package/dist/esm/types.js +1 -0
- package/dist/redact.d.ts +3 -0
- package/dist/redact.js +12 -6
- package/dist/types.d.ts +9 -2
- package/package.json +27 -4
- package/scripts/mark-esm.js +6 -0
- package/src/agent.ts +111 -15
- package/src/browser/client.ts +686 -0
- package/src/browser/index.ts +74 -0
- package/src/browser/instrument.ts +275 -0
- package/src/browser/transport.ts +184 -0
- package/src/browser/types.ts +105 -0
- package/src/browser/version.ts +2 -0
- package/src/browser/vitals.ts +149 -0
- package/src/config.ts +2 -0
- package/src/console.ts +182 -0
- package/src/redact.ts +12 -6
- package/src/types.ts +9 -2
- package/test/browser.test.js +328 -0
- package/test/console.test.js +182 -0
- package/tsconfig.esm.json +14 -0
|
@@ -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
|
+
});
|
|
@@ -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
|
+
});
|