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
@@ -0,0 +1,210 @@
1
+ const noop = () => { };
2
+ /** Instrumentation runs inside the page's own calls; a bug here must never become the page's bug. */
3
+ function safely(fn) {
4
+ try {
5
+ fn();
6
+ }
7
+ catch {
8
+ // swallowed on purpose
9
+ }
10
+ }
11
+ const now = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
12
+ export function instrumentErrors(win, hooks) {
13
+ // Registered in the bubble phase: resource load failures (img, script) don't
14
+ // bubble to window, so only script errors arrive here.
15
+ const onError = (event) => safely(() => {
16
+ const e = event;
17
+ hooks.onError(e.error ?? e.message, "onerror", {
18
+ message: e.message,
19
+ filename: e.filename,
20
+ lineno: e.lineno,
21
+ colno: e.colno,
22
+ });
23
+ });
24
+ const onRejection = (event) => safely(() => hooks.onError(event.reason, "unhandledrejection"));
25
+ win.addEventListener("error", onError);
26
+ win.addEventListener("unhandledrejection", onRejection);
27
+ return () => {
28
+ win.removeEventListener("error", onError);
29
+ win.removeEventListener("unhandledrejection", onRejection);
30
+ };
31
+ }
32
+ export function instrumentFetch(win, original, hooks) {
33
+ if (typeof original !== "function")
34
+ return noop;
35
+ const RequestCtor = typeof Request === "function" ? Request : undefined;
36
+ const wrapped = function fetch(input, init) {
37
+ let url;
38
+ let method = "GET";
39
+ let nextInit = init;
40
+ let spanId;
41
+ try {
42
+ const isRequest = RequestCtor !== undefined && input instanceof RequestCtor;
43
+ url = new URL(isRequest ? input.url : String(input), win.location.href);
44
+ method = String(init?.method ?? (isRequest ? input.method : "GET")).toUpperCase();
45
+ if (hooks.isOwnRequest(url)) {
46
+ url = undefined;
47
+ }
48
+ else {
49
+ const propagation = hooks.propagation(url);
50
+ if (propagation) {
51
+ // fetch(request, init) replaces the request's headers with init's, so
52
+ // start from whichever the caller actually supplied.
53
+ const headers = new Headers(init?.headers ?? (isRequest ? input.headers : undefined));
54
+ for (const [name, value] of Object.entries(propagation.headers)) {
55
+ if (!headers.has(name))
56
+ headers.set(name, value);
57
+ }
58
+ nextInit = { ...init, headers };
59
+ spanId = propagation.spanId;
60
+ }
61
+ }
62
+ }
63
+ catch {
64
+ url = undefined;
65
+ nextInit = init;
66
+ }
67
+ const startedAt = now();
68
+ const promise = original.call(win, input, nextInit);
69
+ if (url) {
70
+ const target = url;
71
+ promise.then((response) => safely(() => hooks.onRequest({ kind: "fetch", method, url: target, status: response.status || undefined, durationMs: now() - startedAt, spanId })), (error) => safely(() => hooks.onRequest({
72
+ kind: "fetch",
73
+ method,
74
+ url: target,
75
+ durationMs: now() - startedAt,
76
+ aborted: error?.name === "AbortError",
77
+ spanId,
78
+ })));
79
+ }
80
+ return promise;
81
+ };
82
+ win.fetch = wrapped;
83
+ return () => {
84
+ if (win.fetch === wrapped)
85
+ win.fetch = original;
86
+ };
87
+ }
88
+ export function instrumentXhr(win, hooks) {
89
+ const Xhr = win.XMLHttpRequest;
90
+ if (typeof Xhr !== "function")
91
+ return noop;
92
+ const proto = Xhr.prototype;
93
+ const originalOpen = proto.open;
94
+ const originalSend = proto.send;
95
+ const originalSetRequestHeader = proto.setRequestHeader;
96
+ const states = new WeakMap();
97
+ const open = function (method, url, ...rest) {
98
+ safely(() => {
99
+ states.set(this, {
100
+ method: String(method || "GET").toUpperCase(),
101
+ url: new URL(String(url), win.location.href),
102
+ headerNames: new Set(),
103
+ });
104
+ });
105
+ return originalOpen.apply(this, [method, url, ...rest]);
106
+ };
107
+ const setRequestHeader = function (name, value) {
108
+ safely(() => states.get(this)?.headerNames.add(String(name).toLowerCase()));
109
+ return originalSetRequestHeader.call(this, name, value);
110
+ };
111
+ const send = function (body) {
112
+ const state = states.get(this);
113
+ if (state?.url && !hooks.isOwnRequest(state.url)) {
114
+ const url = state.url;
115
+ let spanId;
116
+ safely(() => {
117
+ const propagation = hooks.propagation(url);
118
+ if (!propagation)
119
+ return;
120
+ for (const [name, value] of Object.entries(propagation.headers)) {
121
+ // setRequestHeader appends, so a caller's own traceparent would become "a, b".
122
+ if (!state.headerNames.has(name))
123
+ originalSetRequestHeader.call(this, name, value);
124
+ }
125
+ spanId = propagation.spanId;
126
+ });
127
+ const startedAt = now();
128
+ let aborted = false;
129
+ this.addEventListener("abort", () => {
130
+ aborted = true;
131
+ });
132
+ this.addEventListener("loadend", () => safely(() => hooks.onRequest({
133
+ kind: "xhr",
134
+ method: state.method,
135
+ url,
136
+ status: this.status || undefined,
137
+ durationMs: now() - startedAt,
138
+ aborted,
139
+ spanId,
140
+ })));
141
+ }
142
+ return originalSend.call(this, body);
143
+ };
144
+ proto.open = open;
145
+ proto.setRequestHeader = setRequestHeader;
146
+ proto.send = send;
147
+ return () => {
148
+ if (proto.open === open)
149
+ proto.open = originalOpen;
150
+ if (proto.setRequestHeader === setRequestHeader)
151
+ proto.setRequestHeader = originalSetRequestHeader;
152
+ if (proto.send === send)
153
+ proto.send = originalSend;
154
+ };
155
+ }
156
+ export function instrumentConsole(target, levels, hooks) {
157
+ const restores = [];
158
+ for (const level of levels) {
159
+ const original = target[level];
160
+ if (typeof original !== "function")
161
+ continue;
162
+ const wrapped = function (...args) {
163
+ safely(() => hooks.onConsole(level, args));
164
+ return original.apply(target, args);
165
+ };
166
+ target[level] = wrapped;
167
+ restores.push(() => {
168
+ if (target[level] === wrapped)
169
+ target[level] = original;
170
+ });
171
+ }
172
+ return () => restores.forEach((restore) => restore());
173
+ }
174
+ /** Single-page-app route changes, for breadcrumbs and to start a fresh trace per view. */
175
+ export function instrumentHistory(win, hooks) {
176
+ const history = win.history;
177
+ if (!history || typeof history.pushState !== "function")
178
+ return noop;
179
+ let last = win.location.href;
180
+ const changed = () => safely(() => {
181
+ const next = win.location.href;
182
+ if (next === last)
183
+ return;
184
+ const previous = last;
185
+ last = next;
186
+ hooks.onNavigation(previous, next);
187
+ });
188
+ const originalPush = history.pushState;
189
+ const originalReplace = history.replaceState;
190
+ const push = function (...args) {
191
+ const result = originalPush.apply(this, args);
192
+ changed();
193
+ return result;
194
+ };
195
+ const replace = function (...args) {
196
+ const result = originalReplace.apply(this, args);
197
+ changed();
198
+ return result;
199
+ };
200
+ history.pushState = push;
201
+ history.replaceState = replace;
202
+ win.addEventListener("popstate", changed);
203
+ return () => {
204
+ if (history.pushState === push)
205
+ history.pushState = originalPush;
206
+ if (history.replaceState === replace)
207
+ history.replaceState = originalReplace;
208
+ win.removeEventListener("popstate", changed);
209
+ };
210
+ }
@@ -0,0 +1,164 @@
1
+ /** Browsers cap the combined body of in-flight keepalive requests at 64 KiB. */
2
+ const KEEPALIVE_BYTES = 60000;
3
+ const MAX_RETRY_DELAY_MS = 300000;
4
+ /**
5
+ * Batches events and posts them to the ingest API. It never throws into the page
6
+ * and never blocks it: a Midline outage costs queued telemetry, not the app.
7
+ */
8
+ export class BrowserTransport {
9
+ constructor(options) {
10
+ this.options = options;
11
+ this.queue = [];
12
+ this.sending = false;
13
+ this.stopped = false;
14
+ this.retryAt = 0;
15
+ this.backoffMs = 0;
16
+ this.dropped = 0;
17
+ }
18
+ start() {
19
+ if (this.timer || this.stopped)
20
+ return;
21
+ this.timer = setInterval(() => void this.flush(), this.options.flushIntervalMs);
22
+ }
23
+ enqueue(event) {
24
+ if (this.stopped)
25
+ return;
26
+ this.queue.push(event);
27
+ this.trim();
28
+ if (this.queue.length >= this.options.maxBatchSize && Date.now() >= this.retryAt) {
29
+ void this.flush();
30
+ }
31
+ }
32
+ get size() {
33
+ return this.queue.length;
34
+ }
35
+ /** Sends everything queued, one batch at a time, unless Midline asked us to back off. */
36
+ async flush() {
37
+ if (this.stopped || this.sending || !this.queue.length || Date.now() < this.retryAt)
38
+ return;
39
+ this.sending = true;
40
+ try {
41
+ while (this.queue.length && !this.stopped) {
42
+ const batch = this.queue.splice(0, this.options.maxBatchSize);
43
+ const outcome = await this.send(batch, false);
44
+ if (outcome === "retry") {
45
+ this.queue.unshift(...batch);
46
+ this.trim();
47
+ return;
48
+ }
49
+ if (outcome === "stop")
50
+ return;
51
+ }
52
+ }
53
+ finally {
54
+ this.sending = false;
55
+ }
56
+ }
57
+ /**
58
+ * The page is being hidden and may never come back. Hands whatever is queued to
59
+ * keepalive requests without waiting on them; an in-flight regular flush is left
60
+ * alone, since its batch already left the queue.
61
+ */
62
+ flushOnHide() {
63
+ if (this.stopped || !this.queue.length)
64
+ return;
65
+ let budget = KEEPALIVE_BYTES;
66
+ while (this.queue.length) {
67
+ const batch = [];
68
+ let bytes = 12;
69
+ while (this.queue.length && batch.length < this.options.maxBatchSize) {
70
+ const size = JSON.stringify(this.queue[0]).length + 1;
71
+ if (batch.length && bytes + size > budget)
72
+ break;
73
+ batch.push(this.queue.shift());
74
+ bytes += size;
75
+ }
76
+ budget -= bytes;
77
+ void this.send(batch, budget > 0);
78
+ if (budget <= 0)
79
+ break;
80
+ }
81
+ }
82
+ stop() {
83
+ this.stopped = true;
84
+ this.queue = [];
85
+ if (this.timer)
86
+ clearInterval(this.timer);
87
+ this.timer = undefined;
88
+ }
89
+ trim() {
90
+ const excess = this.queue.length - this.options.maxQueueSize;
91
+ if (excess > 0) {
92
+ this.queue.splice(0, excess);
93
+ this.dropped += excess;
94
+ }
95
+ }
96
+ async send(batch, keepalive) {
97
+ let response;
98
+ try {
99
+ response = await this.options.fetch(this.options.batchUrl, {
100
+ method: "POST",
101
+ headers: { "Content-Type": "application/json", "X-API-Key": this.options.apiKey },
102
+ body: JSON.stringify({ events: batch }),
103
+ keepalive,
104
+ credentials: "omit",
105
+ mode: "cors",
106
+ });
107
+ }
108
+ catch (error) {
109
+ // Offline, blocked by an extension, or a server that doesn't answer CORS.
110
+ this.options.debug(`midline: could not reach Midline (${describe(error)}); will retry.`);
111
+ this.backOff();
112
+ return "retry";
113
+ }
114
+ const { status } = response;
115
+ if (status >= 200 && status < 300) {
116
+ this.backoffMs = 0;
117
+ this.retryAt = 0;
118
+ return "ok";
119
+ }
120
+ if (status === 401 || status === 403) {
121
+ const detail = await serverMessage(response);
122
+ this.stop();
123
+ this.options.onStop(`midline: the Midline server refused this key (HTTP ${status}${detail ? `: ${detail}` : ""}). ` +
124
+ "Browser monitoring is now off.");
125
+ return "stop";
126
+ }
127
+ if (status === 408 || status === 429 || status >= 500) {
128
+ this.backOff(parseRetryAfter(response.headers.get("retry-after")));
129
+ this.options.debug(`midline: Midline answered HTTP ${status}; events are kept and retried.`);
130
+ return "retry";
131
+ }
132
+ this.dropped += batch.length;
133
+ const detail = await serverMessage(response);
134
+ this.options.debug(`midline: Midline rejected ${batch.length} events (HTTP ${status}${detail ? `: ${detail}` : ""}).`);
135
+ return "drop";
136
+ }
137
+ backOff(retryAfterMs = 0) {
138
+ this.backoffMs = Math.min(Math.max(this.backoffMs * 2, 1000), MAX_RETRY_DELAY_MS);
139
+ this.retryAt = Date.now() + Math.max(this.backoffMs, Math.min(retryAfterMs, MAX_RETRY_DELAY_MS));
140
+ }
141
+ }
142
+ function parseRetryAfter(value) {
143
+ if (!value)
144
+ return 0;
145
+ const seconds = Number(value);
146
+ if (Number.isFinite(seconds))
147
+ return Math.max(0, seconds * 1000);
148
+ const date = Date.parse(value);
149
+ return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0;
150
+ }
151
+ async function serverMessage(response) {
152
+ try {
153
+ const text = await response.text();
154
+ const parsed = JSON.parse(text);
155
+ const message = Array.isArray(parsed?.message) ? parsed.message.join("; ") : parsed?.message;
156
+ return typeof message === "string" ? message.slice(0, 300) : "";
157
+ }
158
+ catch {
159
+ return "";
160
+ }
161
+ }
162
+ function describe(error) {
163
+ return error instanceof Error ? error.message : String(error);
164
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,2 @@
1
+ /** Kept in step with package.json by test/browser.test.js; a browser bundle can't read package.json. */
2
+ export const BROWSER_SDK_VERSION = "0.4.0";
@@ -0,0 +1,132 @@
1
+ /** web.dev thresholds: at or under the first is good, over the second is poor. */
2
+ const THRESHOLDS = {
3
+ LCP: [2500, 4000],
4
+ INP: [200, 500],
5
+ CLS: [0.1, 0.25],
6
+ FCP: [1800, 3000],
7
+ TTFB: [800, 1800],
8
+ };
9
+ const rate = (name, value) => value <= THRESHOLDS[name][0] ? "good" : value <= THRESHOLDS[name][1] ? "needs-improvement" : "poor";
10
+ /**
11
+ * Core Web Vitals without a dependency, measured the way the web-vitals library
12
+ * does and reported once, when the page is first hidden (the last moment a
13
+ * report reliably gets out). Browsers that don't expose an entry type simply
14
+ * omit that metric: Safari has no LCP, INP or CLS.
15
+ */
16
+ export function observeVitals(win, onReport) {
17
+ const Observer = win.PerformanceObserver;
18
+ const supported = Observer?.supportedEntryTypes ?? [];
19
+ const perf = win.performance;
20
+ const doc = win.document;
21
+ const values = {};
22
+ const observers = [];
23
+ let activationStart = 0;
24
+ let navigationType;
25
+ try {
26
+ const navigation = perf?.getEntriesByType?.("navigation")?.[0];
27
+ if (navigation) {
28
+ activationStart = navigation.activationStart ?? 0;
29
+ navigationType = navigation.type;
30
+ if (typeof navigation.responseStart === "number" && navigation.responseStart > 0) {
31
+ values.TTFB = Math.max(0, navigation.responseStart - activationStart);
32
+ }
33
+ }
34
+ }
35
+ catch {
36
+ // no navigation timing
37
+ }
38
+ const observe = (type, handle, extra = {}) => {
39
+ if (!Observer || !supported.includes(type))
40
+ return;
41
+ try {
42
+ const observer = new Observer((list) => handle(list.getEntries()));
43
+ observer.observe({ type, buffered: true, ...extra });
44
+ observers.push({ observer, handle });
45
+ }
46
+ catch {
47
+ // unsupported option in this browser
48
+ }
49
+ };
50
+ observe("paint", (entries) => {
51
+ for (const entry of entries) {
52
+ if (entry.name === "first-contentful-paint")
53
+ values.FCP = Math.max(0, entry.startTime - activationStart);
54
+ }
55
+ });
56
+ observe("largest-contentful-paint", (entries) => {
57
+ const last = entries[entries.length - 1];
58
+ if (last)
59
+ values.LCP = Math.max(0, last.startTime - activationStart);
60
+ });
61
+ // CLS is the largest session window: shifts less than 1s apart, at most 5s long.
62
+ let windowValue = 0;
63
+ let windowStart = 0;
64
+ let windowLast = 0;
65
+ observe("layout-shift", (entries) => {
66
+ for (const entry of entries) {
67
+ if (entry.hadRecentInput || typeof entry.value !== "number")
68
+ continue;
69
+ if (windowValue && entry.startTime - windowLast < 1000 && entry.startTime - windowStart < 5000) {
70
+ windowValue += entry.value;
71
+ }
72
+ else {
73
+ windowValue = entry.value;
74
+ windowStart = entry.startTime;
75
+ }
76
+ windowLast = entry.startTime;
77
+ values.CLS = Math.max(values.CLS ?? 0, windowValue);
78
+ }
79
+ });
80
+ // INP: the slowest interaction, or near the 98th percentile on busy pages.
81
+ const interactions = new Map();
82
+ const recordInteractions = (entries) => {
83
+ for (const entry of entries) {
84
+ if (!entry.interactionId)
85
+ continue;
86
+ interactions.set(entry.interactionId, Math.max(interactions.get(entry.interactionId) ?? 0, entry.duration));
87
+ }
88
+ };
89
+ observe("event", recordInteractions, { durationThreshold: 40 });
90
+ observe("first-input", recordInteractions);
91
+ let reported = false;
92
+ const report = () => {
93
+ if (reported)
94
+ return;
95
+ reported = true;
96
+ for (const { observer, handle } of observers) {
97
+ try {
98
+ handle(observer.takeRecords());
99
+ observer.disconnect();
100
+ }
101
+ catch {
102
+ // already disconnected
103
+ }
104
+ }
105
+ if (interactions.size) {
106
+ const durations = [...interactions.values()].sort((a, b) => b - a);
107
+ values.INP = durations[Math.min(durations.length - 1, Math.floor(durations.length / 50))];
108
+ }
109
+ const vitals = {};
110
+ for (const name of Object.keys(values)) {
111
+ const raw = values[name];
112
+ if (typeof raw !== "number" || !Number.isFinite(raw))
113
+ continue;
114
+ const value = name === "CLS" ? Math.round(raw * 10000) / 10000 : Math.round(raw);
115
+ vitals[name] = { value, rating: rate(name, value) };
116
+ }
117
+ if (Object.keys(vitals).length)
118
+ onReport({ vitals, navigationType });
119
+ };
120
+ const onVisibility = () => {
121
+ if (doc?.visibilityState === "hidden")
122
+ report();
123
+ };
124
+ doc?.addEventListener("visibilitychange", onVisibility, true);
125
+ win.addEventListener("pagehide", report, true);
126
+ return () => {
127
+ doc?.removeEventListener("visibilitychange", onVisibility, true);
128
+ win.removeEventListener("pagehide", report, true);
129
+ for (const { observer } of observers)
130
+ observer.disconnect();
131
+ };
132
+ }
@@ -0,0 +1 @@
1
+ { "type": "module" }