midline-agent 0.3.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 +89 -4
- package/browser/package.json +8 -0
- 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/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/package.json +27 -4
- package/scripts/mark-esm.js +6 -0
- 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/redact.ts +12 -6
- package/test/browser.test.js +328 -0
- package/tsconfig.esm.json +14 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
export type VitalName = "LCP" | "INP" | "CLS" | "FCP" | "TTFB";
|
|
2
|
+
export type VitalRating = "good" | "needs-improvement" | "poor";
|
|
3
|
+
|
|
4
|
+
export interface VitalsReport {
|
|
5
|
+
vitals: Partial<Record<VitalName, { value: number; rating: VitalRating }>>;
|
|
6
|
+
navigationType?: string;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** web.dev thresholds: at or under the first is good, over the second is poor. */
|
|
10
|
+
const THRESHOLDS: Record<VitalName, [number, number]> = {
|
|
11
|
+
LCP: [2500, 4000],
|
|
12
|
+
INP: [200, 500],
|
|
13
|
+
CLS: [0.1, 0.25],
|
|
14
|
+
FCP: [1800, 3000],
|
|
15
|
+
TTFB: [800, 1800],
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
const rate = (name: VitalName, value: number): VitalRating =>
|
|
19
|
+
value <= THRESHOLDS[name][0] ? "good" : value <= THRESHOLDS[name][1] ? "needs-improvement" : "poor";
|
|
20
|
+
|
|
21
|
+
type Entry = PerformanceEntry & {
|
|
22
|
+
value?: number;
|
|
23
|
+
hadRecentInput?: boolean;
|
|
24
|
+
interactionId?: number;
|
|
25
|
+
activationStart?: number;
|
|
26
|
+
responseStart?: number;
|
|
27
|
+
type?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Core Web Vitals without a dependency, measured the way the web-vitals library
|
|
32
|
+
* does and reported once, when the page is first hidden (the last moment a
|
|
33
|
+
* report reliably gets out). Browsers that don't expose an entry type simply
|
|
34
|
+
* omit that metric: Safari has no LCP, INP or CLS.
|
|
35
|
+
*/
|
|
36
|
+
export function observeVitals(win: Window, onReport: (report: VitalsReport) => void): () => void {
|
|
37
|
+
const Observer = (win as Window & { PerformanceObserver?: typeof PerformanceObserver }).PerformanceObserver;
|
|
38
|
+
const supported: readonly string[] = Observer?.supportedEntryTypes ?? [];
|
|
39
|
+
const perf = win.performance;
|
|
40
|
+
const doc = win.document;
|
|
41
|
+
|
|
42
|
+
const values: Partial<Record<VitalName, number>> = {};
|
|
43
|
+
const observers: Array<{ observer: PerformanceObserver; handle: (entries: Entry[]) => void }> = [];
|
|
44
|
+
|
|
45
|
+
let activationStart = 0;
|
|
46
|
+
let navigationType: string | undefined;
|
|
47
|
+
try {
|
|
48
|
+
const navigation = perf?.getEntriesByType?.("navigation")?.[0] as Entry | undefined;
|
|
49
|
+
if (navigation) {
|
|
50
|
+
activationStart = navigation.activationStart ?? 0;
|
|
51
|
+
navigationType = navigation.type;
|
|
52
|
+
if (typeof navigation.responseStart === "number" && navigation.responseStart > 0) {
|
|
53
|
+
values.TTFB = Math.max(0, navigation.responseStart - activationStart);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
} catch {
|
|
57
|
+
// no navigation timing
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const observe = (type: string, handle: (entries: Entry[]) => void, extra: Record<string, unknown> = {}) => {
|
|
61
|
+
if (!Observer || !supported.includes(type)) return;
|
|
62
|
+
try {
|
|
63
|
+
const observer = new Observer((list) => handle(list.getEntries() as Entry[]));
|
|
64
|
+
observer.observe({ type, buffered: true, ...extra } as PerformanceObserverInit);
|
|
65
|
+
observers.push({ observer, handle });
|
|
66
|
+
} catch {
|
|
67
|
+
// unsupported option in this browser
|
|
68
|
+
}
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
observe("paint", (entries) => {
|
|
72
|
+
for (const entry of entries) {
|
|
73
|
+
if (entry.name === "first-contentful-paint") values.FCP = Math.max(0, entry.startTime - activationStart);
|
|
74
|
+
}
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
observe("largest-contentful-paint", (entries) => {
|
|
78
|
+
const last = entries[entries.length - 1];
|
|
79
|
+
if (last) values.LCP = Math.max(0, last.startTime - activationStart);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
// CLS is the largest session window: shifts less than 1s apart, at most 5s long.
|
|
83
|
+
let windowValue = 0;
|
|
84
|
+
let windowStart = 0;
|
|
85
|
+
let windowLast = 0;
|
|
86
|
+
observe("layout-shift", (entries) => {
|
|
87
|
+
for (const entry of entries) {
|
|
88
|
+
if (entry.hadRecentInput || typeof entry.value !== "number") continue;
|
|
89
|
+
if (windowValue && entry.startTime - windowLast < 1000 && entry.startTime - windowStart < 5000) {
|
|
90
|
+
windowValue += entry.value;
|
|
91
|
+
} else {
|
|
92
|
+
windowValue = entry.value;
|
|
93
|
+
windowStart = entry.startTime;
|
|
94
|
+
}
|
|
95
|
+
windowLast = entry.startTime;
|
|
96
|
+
values.CLS = Math.max(values.CLS ?? 0, windowValue);
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
// INP: the slowest interaction, or near the 98th percentile on busy pages.
|
|
101
|
+
const interactions = new Map<number, number>();
|
|
102
|
+
const recordInteractions = (entries: Entry[]) => {
|
|
103
|
+
for (const entry of entries) {
|
|
104
|
+
if (!entry.interactionId) continue;
|
|
105
|
+
interactions.set(entry.interactionId, Math.max(interactions.get(entry.interactionId) ?? 0, entry.duration));
|
|
106
|
+
}
|
|
107
|
+
};
|
|
108
|
+
observe("event", recordInteractions, { durationThreshold: 40 });
|
|
109
|
+
observe("first-input", recordInteractions);
|
|
110
|
+
|
|
111
|
+
let reported = false;
|
|
112
|
+
const report = () => {
|
|
113
|
+
if (reported) return;
|
|
114
|
+
reported = true;
|
|
115
|
+
for (const { observer, handle } of observers) {
|
|
116
|
+
try {
|
|
117
|
+
handle(observer.takeRecords() as Entry[]);
|
|
118
|
+
observer.disconnect();
|
|
119
|
+
} catch {
|
|
120
|
+
// already disconnected
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (interactions.size) {
|
|
124
|
+
const durations = [...interactions.values()].sort((a, b) => b - a);
|
|
125
|
+
values.INP = durations[Math.min(durations.length - 1, Math.floor(durations.length / 50))];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const vitals: VitalsReport["vitals"] = {};
|
|
129
|
+
for (const name of Object.keys(values) as VitalName[]) {
|
|
130
|
+
const raw = values[name];
|
|
131
|
+
if (typeof raw !== "number" || !Number.isFinite(raw)) continue;
|
|
132
|
+
const value = name === "CLS" ? Math.round(raw * 10_000) / 10_000 : Math.round(raw);
|
|
133
|
+
vitals[name] = { value, rating: rate(name, value) };
|
|
134
|
+
}
|
|
135
|
+
if (Object.keys(vitals).length) onReport({ vitals, navigationType });
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
const onVisibility = () => {
|
|
139
|
+
if (doc?.visibilityState === "hidden") report();
|
|
140
|
+
};
|
|
141
|
+
doc?.addEventListener("visibilitychange", onVisibility, true);
|
|
142
|
+
win.addEventListener("pagehide", report, true);
|
|
143
|
+
|
|
144
|
+
return () => {
|
|
145
|
+
doc?.removeEventListener("visibilitychange", onVisibility, true);
|
|
146
|
+
win.removeEventListener("pagehide", report, true);
|
|
147
|
+
for (const { observer } of observers) observer.disconnect();
|
|
148
|
+
};
|
|
149
|
+
}
|
package/src/redact.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
+
* Shared by the Node agent and the browser SDK, so nothing here may touch a
|
|
3
|
+
* Node-only global (Buffer, process) — TextEncoder and URLSearchParams exist in both.
|
|
4
|
+
*
|
|
2
5
|
* Redaction happens in the host process, before an event is queued. Whatever is
|
|
3
6
|
* removed here never reaches a socket, a log line or the Midline server.
|
|
4
7
|
*
|
|
@@ -124,7 +127,8 @@ export class Redactor {
|
|
|
124
127
|
if (typeof input === "bigint") return input.toString();
|
|
125
128
|
if (typeof input === "function" || typeof input === "symbol") return undefined;
|
|
126
129
|
if (input instanceof Date) return Number.isNaN(input.getTime()) ? null : input.toISOString();
|
|
127
|
-
|
|
130
|
+
// Buffer is a Uint8Array, so this also covers it without naming a Node-only global.
|
|
131
|
+
if (ArrayBuffer.isView(input)) {
|
|
128
132
|
return `[Binary ${(input as ArrayBufferView).byteLength} bytes]`;
|
|
129
133
|
}
|
|
130
134
|
if (typeof input !== "object") return undefined;
|
|
@@ -190,11 +194,13 @@ export class Redactor {
|
|
|
190
194
|
|
|
191
195
|
const type = (contentType || "").toLowerCase();
|
|
192
196
|
|
|
193
|
-
if (typeof raw === "object" && !
|
|
197
|
+
if (typeof raw === "object" && !ArrayBuffer.isView(raw)) {
|
|
194
198
|
return this.fit(this.value(raw), maxBytes);
|
|
195
199
|
}
|
|
196
200
|
|
|
197
|
-
const text =
|
|
201
|
+
const text = ArrayBuffer.isView(raw)
|
|
202
|
+
? new TextDecoder().decode(raw as ArrayBufferView)
|
|
203
|
+
: String(raw);
|
|
198
204
|
|
|
199
205
|
if (type.includes("json")) {
|
|
200
206
|
try {
|
|
@@ -214,18 +220,18 @@ export class Redactor {
|
|
|
214
220
|
|
|
215
221
|
private fit(value: unknown, maxBytes: number): { body?: unknown; truncated?: boolean } {
|
|
216
222
|
const serialized = JSON.stringify(value) ?? "";
|
|
217
|
-
if (
|
|
223
|
+
if (new TextEncoder().encode(serialized).length <= maxBytes) {
|
|
218
224
|
return { body: value };
|
|
219
225
|
}
|
|
220
226
|
return this.cut(serialized, maxBytes);
|
|
221
227
|
}
|
|
222
228
|
|
|
223
229
|
private cut(text: string, maxBytes: number): { body?: unknown; truncated?: boolean } {
|
|
224
|
-
const bytes =
|
|
230
|
+
const bytes = new TextEncoder().encode(text);
|
|
225
231
|
if (bytes.length <= maxBytes) {
|
|
226
232
|
return { body: text };
|
|
227
233
|
}
|
|
228
234
|
// Slicing bytes can split a multi-byte character; the replacement char is harmless here.
|
|
229
|
-
return { body: bytes.subarray(0, maxBytes)
|
|
235
|
+
return { body: new TextDecoder().decode(bytes.subarray(0, maxBytes)), truncated: true };
|
|
230
236
|
}
|
|
231
237
|
}
|
|
@@ -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
|
+
});
|