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,65 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BROWSER_SDK_VERSION = exports.resolveBatchUrl = exports.Midline = void 0;
|
|
4
|
+
exports.init = init;
|
|
5
|
+
exports.captureException = captureException;
|
|
6
|
+
exports.captureMessage = captureMessage;
|
|
7
|
+
exports.setUser = setUser;
|
|
8
|
+
exports.setTag = setTag;
|
|
9
|
+
exports.addBreadcrumb = addBreadcrumb;
|
|
10
|
+
exports.flush = flush;
|
|
11
|
+
exports.close = close;
|
|
12
|
+
/**
|
|
13
|
+
* midline-agent/browser — error, network and Web Vitals monitoring for web apps.
|
|
14
|
+
*
|
|
15
|
+
* import * as Midline from "midline-agent/browser";
|
|
16
|
+
* Midline.init({ apiKey: "pk_…", service: "checkout-web" });
|
|
17
|
+
*
|
|
18
|
+
* Framework-agnostic: it instruments the page (window errors, fetch, XHR,
|
|
19
|
+
* history), not React, Vue or Angular, so it works under all of them. Call
|
|
20
|
+
* `captureException` from a framework's own error hook for errors it swallows.
|
|
21
|
+
*/
|
|
22
|
+
const client_js_1 = require("./client.js");
|
|
23
|
+
let client;
|
|
24
|
+
/**
|
|
25
|
+
* Starts monitoring. Safe to call during server-side rendering (it does nothing
|
|
26
|
+
* without a window) and safe to call twice (the first instance is closed).
|
|
27
|
+
* A bad config logs one console warning and leaves the page untouched.
|
|
28
|
+
*/
|
|
29
|
+
function init(config) {
|
|
30
|
+
if (client)
|
|
31
|
+
void client.close();
|
|
32
|
+
client = client_js_1.BrowserClient.create(config);
|
|
33
|
+
}
|
|
34
|
+
/** Reports an error your code caught, e.g. from a React error boundary. */
|
|
35
|
+
function captureException(error, context) {
|
|
36
|
+
client?.captureException(error, context);
|
|
37
|
+
}
|
|
38
|
+
function captureMessage(message, severity, context) {
|
|
39
|
+
client?.captureMessage(message, severity, context);
|
|
40
|
+
}
|
|
41
|
+
/** Attaches a user id to later events. Pass null on sign-out. */
|
|
42
|
+
function setUser(user) {
|
|
43
|
+
client?.setUser(user);
|
|
44
|
+
}
|
|
45
|
+
function setTag(key, value) {
|
|
46
|
+
client?.setTag(key, value);
|
|
47
|
+
}
|
|
48
|
+
function addBreadcrumb(message, type) {
|
|
49
|
+
client?.addBreadcrumb(message, type);
|
|
50
|
+
}
|
|
51
|
+
/** Sends anything queued now. */
|
|
52
|
+
function flush() {
|
|
53
|
+
return client ? client.flush() : Promise.resolve();
|
|
54
|
+
}
|
|
55
|
+
/** Restores everything the SDK wrapped and sends what's queued. */
|
|
56
|
+
async function close() {
|
|
57
|
+
const closing = client;
|
|
58
|
+
client = undefined;
|
|
59
|
+
await closing?.close();
|
|
60
|
+
}
|
|
61
|
+
exports.Midline = { init, captureException, captureMessage, setUser, setTag, addBreadcrumb, flush, close };
|
|
62
|
+
var client_js_2 = require("./client.js");
|
|
63
|
+
Object.defineProperty(exports, "resolveBatchUrl", { enumerable: true, get: function () { return client_js_2.resolveBatchUrl; } });
|
|
64
|
+
var version_js_1 = require("./version.js");
|
|
65
|
+
Object.defineProperty(exports, "BROWSER_SDK_VERSION", { enumerable: true, get: function () { return version_js_1.BROWSER_SDK_VERSION; } });
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { ConsoleLevel } from "./types.js";
|
|
2
|
+
/** A finished fetch or XHR call. `status` is absent when no response arrived. */
|
|
3
|
+
export interface RequestRecord {
|
|
4
|
+
kind: "fetch" | "xhr";
|
|
5
|
+
method: string;
|
|
6
|
+
url: URL;
|
|
7
|
+
status?: number;
|
|
8
|
+
durationMs: number;
|
|
9
|
+
aborted?: boolean;
|
|
10
|
+
spanId?: string;
|
|
11
|
+
}
|
|
12
|
+
export interface Propagation {
|
|
13
|
+
headers: Record<string, string>;
|
|
14
|
+
spanId: string;
|
|
15
|
+
}
|
|
16
|
+
export interface InstrumentHooks {
|
|
17
|
+
onError(error: unknown, mechanism: "onerror" | "unhandledrejection", location?: ErrorLocation): void;
|
|
18
|
+
onRequest(record: RequestRecord): void;
|
|
19
|
+
onConsole(level: ConsoleLevel, args: unknown[]): void;
|
|
20
|
+
onNavigation(from: string, to: string): void;
|
|
21
|
+
/** Headers to add to a request, or undefined to leave it untouched. */
|
|
22
|
+
propagation(url: URL): Propagation | undefined;
|
|
23
|
+
/** Midline's own delivery requests, which are never recorded. */
|
|
24
|
+
isOwnRequest(url: URL): boolean;
|
|
25
|
+
}
|
|
26
|
+
export interface ErrorLocation {
|
|
27
|
+
message?: string;
|
|
28
|
+
filename?: string;
|
|
29
|
+
lineno?: number;
|
|
30
|
+
colno?: number;
|
|
31
|
+
}
|
|
32
|
+
type Teardown = () => void;
|
|
33
|
+
export declare function instrumentErrors(win: Window, hooks: InstrumentHooks): Teardown;
|
|
34
|
+
export declare function instrumentFetch(win: Window, original: typeof fetch, hooks: InstrumentHooks): Teardown;
|
|
35
|
+
export declare function instrumentXhr(win: Window, hooks: InstrumentHooks): Teardown;
|
|
36
|
+
export declare function instrumentConsole(target: Console, levels: ConsoleLevel[], hooks: InstrumentHooks): Teardown;
|
|
37
|
+
/** Single-page-app route changes, for breadcrumbs and to start a fresh trace per view. */
|
|
38
|
+
export declare function instrumentHistory(win: Window, hooks: InstrumentHooks): Teardown;
|
|
39
|
+
export {};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.instrumentErrors = instrumentErrors;
|
|
4
|
+
exports.instrumentFetch = instrumentFetch;
|
|
5
|
+
exports.instrumentXhr = instrumentXhr;
|
|
6
|
+
exports.instrumentConsole = instrumentConsole;
|
|
7
|
+
exports.instrumentHistory = instrumentHistory;
|
|
8
|
+
const noop = () => { };
|
|
9
|
+
/** Instrumentation runs inside the page's own calls; a bug here must never become the page's bug. */
|
|
10
|
+
function safely(fn) {
|
|
11
|
+
try {
|
|
12
|
+
fn();
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
// swallowed on purpose
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
const now = () => typeof performance !== "undefined" && typeof performance.now === "function" ? performance.now() : Date.now();
|
|
19
|
+
function instrumentErrors(win, hooks) {
|
|
20
|
+
// Registered in the bubble phase: resource load failures (img, script) don't
|
|
21
|
+
// bubble to window, so only script errors arrive here.
|
|
22
|
+
const onError = (event) => safely(() => {
|
|
23
|
+
const e = event;
|
|
24
|
+
hooks.onError(e.error ?? e.message, "onerror", {
|
|
25
|
+
message: e.message,
|
|
26
|
+
filename: e.filename,
|
|
27
|
+
lineno: e.lineno,
|
|
28
|
+
colno: e.colno,
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
const onRejection = (event) => safely(() => hooks.onError(event.reason, "unhandledrejection"));
|
|
32
|
+
win.addEventListener("error", onError);
|
|
33
|
+
win.addEventListener("unhandledrejection", onRejection);
|
|
34
|
+
return () => {
|
|
35
|
+
win.removeEventListener("error", onError);
|
|
36
|
+
win.removeEventListener("unhandledrejection", onRejection);
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
function instrumentFetch(win, original, hooks) {
|
|
40
|
+
if (typeof original !== "function")
|
|
41
|
+
return noop;
|
|
42
|
+
const RequestCtor = typeof Request === "function" ? Request : undefined;
|
|
43
|
+
const wrapped = function fetch(input, init) {
|
|
44
|
+
let url;
|
|
45
|
+
let method = "GET";
|
|
46
|
+
let nextInit = init;
|
|
47
|
+
let spanId;
|
|
48
|
+
try {
|
|
49
|
+
const isRequest = RequestCtor !== undefined && input instanceof RequestCtor;
|
|
50
|
+
url = new URL(isRequest ? input.url : String(input), win.location.href);
|
|
51
|
+
method = String(init?.method ?? (isRequest ? input.method : "GET")).toUpperCase();
|
|
52
|
+
if (hooks.isOwnRequest(url)) {
|
|
53
|
+
url = undefined;
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
const propagation = hooks.propagation(url);
|
|
57
|
+
if (propagation) {
|
|
58
|
+
// fetch(request, init) replaces the request's headers with init's, so
|
|
59
|
+
// start from whichever the caller actually supplied.
|
|
60
|
+
const headers = new Headers(init?.headers ?? (isRequest ? input.headers : undefined));
|
|
61
|
+
for (const [name, value] of Object.entries(propagation.headers)) {
|
|
62
|
+
if (!headers.has(name))
|
|
63
|
+
headers.set(name, value);
|
|
64
|
+
}
|
|
65
|
+
nextInit = { ...init, headers };
|
|
66
|
+
spanId = propagation.spanId;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
url = undefined;
|
|
72
|
+
nextInit = init;
|
|
73
|
+
}
|
|
74
|
+
const startedAt = now();
|
|
75
|
+
const promise = original.call(win, input, nextInit);
|
|
76
|
+
if (url) {
|
|
77
|
+
const target = url;
|
|
78
|
+
promise.then((response) => safely(() => hooks.onRequest({ kind: "fetch", method, url: target, status: response.status || undefined, durationMs: now() - startedAt, spanId })), (error) => safely(() => hooks.onRequest({
|
|
79
|
+
kind: "fetch",
|
|
80
|
+
method,
|
|
81
|
+
url: target,
|
|
82
|
+
durationMs: now() - startedAt,
|
|
83
|
+
aborted: error?.name === "AbortError",
|
|
84
|
+
spanId,
|
|
85
|
+
})));
|
|
86
|
+
}
|
|
87
|
+
return promise;
|
|
88
|
+
};
|
|
89
|
+
win.fetch = wrapped;
|
|
90
|
+
return () => {
|
|
91
|
+
if (win.fetch === wrapped)
|
|
92
|
+
win.fetch = original;
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
function instrumentXhr(win, hooks) {
|
|
96
|
+
const Xhr = win.XMLHttpRequest;
|
|
97
|
+
if (typeof Xhr !== "function")
|
|
98
|
+
return noop;
|
|
99
|
+
const proto = Xhr.prototype;
|
|
100
|
+
const originalOpen = proto.open;
|
|
101
|
+
const originalSend = proto.send;
|
|
102
|
+
const originalSetRequestHeader = proto.setRequestHeader;
|
|
103
|
+
const states = new WeakMap();
|
|
104
|
+
const open = function (method, url, ...rest) {
|
|
105
|
+
safely(() => {
|
|
106
|
+
states.set(this, {
|
|
107
|
+
method: String(method || "GET").toUpperCase(),
|
|
108
|
+
url: new URL(String(url), win.location.href),
|
|
109
|
+
headerNames: new Set(),
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
return originalOpen.apply(this, [method, url, ...rest]);
|
|
113
|
+
};
|
|
114
|
+
const setRequestHeader = function (name, value) {
|
|
115
|
+
safely(() => states.get(this)?.headerNames.add(String(name).toLowerCase()));
|
|
116
|
+
return originalSetRequestHeader.call(this, name, value);
|
|
117
|
+
};
|
|
118
|
+
const send = function (body) {
|
|
119
|
+
const state = states.get(this);
|
|
120
|
+
if (state?.url && !hooks.isOwnRequest(state.url)) {
|
|
121
|
+
const url = state.url;
|
|
122
|
+
let spanId;
|
|
123
|
+
safely(() => {
|
|
124
|
+
const propagation = hooks.propagation(url);
|
|
125
|
+
if (!propagation)
|
|
126
|
+
return;
|
|
127
|
+
for (const [name, value] of Object.entries(propagation.headers)) {
|
|
128
|
+
// setRequestHeader appends, so a caller's own traceparent would become "a, b".
|
|
129
|
+
if (!state.headerNames.has(name))
|
|
130
|
+
originalSetRequestHeader.call(this, name, value);
|
|
131
|
+
}
|
|
132
|
+
spanId = propagation.spanId;
|
|
133
|
+
});
|
|
134
|
+
const startedAt = now();
|
|
135
|
+
let aborted = false;
|
|
136
|
+
this.addEventListener("abort", () => {
|
|
137
|
+
aborted = true;
|
|
138
|
+
});
|
|
139
|
+
this.addEventListener("loadend", () => safely(() => hooks.onRequest({
|
|
140
|
+
kind: "xhr",
|
|
141
|
+
method: state.method,
|
|
142
|
+
url,
|
|
143
|
+
status: this.status || undefined,
|
|
144
|
+
durationMs: now() - startedAt,
|
|
145
|
+
aborted,
|
|
146
|
+
spanId,
|
|
147
|
+
})));
|
|
148
|
+
}
|
|
149
|
+
return originalSend.call(this, body);
|
|
150
|
+
};
|
|
151
|
+
proto.open = open;
|
|
152
|
+
proto.setRequestHeader = setRequestHeader;
|
|
153
|
+
proto.send = send;
|
|
154
|
+
return () => {
|
|
155
|
+
if (proto.open === open)
|
|
156
|
+
proto.open = originalOpen;
|
|
157
|
+
if (proto.setRequestHeader === setRequestHeader)
|
|
158
|
+
proto.setRequestHeader = originalSetRequestHeader;
|
|
159
|
+
if (proto.send === send)
|
|
160
|
+
proto.send = originalSend;
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
function instrumentConsole(target, levels, hooks) {
|
|
164
|
+
const restores = [];
|
|
165
|
+
for (const level of levels) {
|
|
166
|
+
const original = target[level];
|
|
167
|
+
if (typeof original !== "function")
|
|
168
|
+
continue;
|
|
169
|
+
const wrapped = function (...args) {
|
|
170
|
+
safely(() => hooks.onConsole(level, args));
|
|
171
|
+
return original.apply(target, args);
|
|
172
|
+
};
|
|
173
|
+
target[level] = wrapped;
|
|
174
|
+
restores.push(() => {
|
|
175
|
+
if (target[level] === wrapped)
|
|
176
|
+
target[level] = original;
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
return () => restores.forEach((restore) => restore());
|
|
180
|
+
}
|
|
181
|
+
/** Single-page-app route changes, for breadcrumbs and to start a fresh trace per view. */
|
|
182
|
+
function instrumentHistory(win, hooks) {
|
|
183
|
+
const history = win.history;
|
|
184
|
+
if (!history || typeof history.pushState !== "function")
|
|
185
|
+
return noop;
|
|
186
|
+
let last = win.location.href;
|
|
187
|
+
const changed = () => safely(() => {
|
|
188
|
+
const next = win.location.href;
|
|
189
|
+
if (next === last)
|
|
190
|
+
return;
|
|
191
|
+
const previous = last;
|
|
192
|
+
last = next;
|
|
193
|
+
hooks.onNavigation(previous, next);
|
|
194
|
+
});
|
|
195
|
+
const originalPush = history.pushState;
|
|
196
|
+
const originalReplace = history.replaceState;
|
|
197
|
+
const push = function (...args) {
|
|
198
|
+
const result = originalPush.apply(this, args);
|
|
199
|
+
changed();
|
|
200
|
+
return result;
|
|
201
|
+
};
|
|
202
|
+
const replace = function (...args) {
|
|
203
|
+
const result = originalReplace.apply(this, args);
|
|
204
|
+
changed();
|
|
205
|
+
return result;
|
|
206
|
+
};
|
|
207
|
+
history.pushState = push;
|
|
208
|
+
history.replaceState = replace;
|
|
209
|
+
win.addEventListener("popstate", changed);
|
|
210
|
+
return () => {
|
|
211
|
+
if (history.pushState === push)
|
|
212
|
+
history.pushState = originalPush;
|
|
213
|
+
if (history.replaceState === replace)
|
|
214
|
+
history.replaceState = originalReplace;
|
|
215
|
+
win.removeEventListener("popstate", changed);
|
|
216
|
+
};
|
|
217
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { BrowserEvent } from "./types.js";
|
|
2
|
+
export interface TransportOptions {
|
|
3
|
+
batchUrl: string;
|
|
4
|
+
apiKey: string;
|
|
5
|
+
/** The page's fetch as it was before instrumentation, so delivery is never recorded as traffic. */
|
|
6
|
+
fetch: typeof fetch;
|
|
7
|
+
flushIntervalMs: number;
|
|
8
|
+
maxBatchSize: number;
|
|
9
|
+
maxQueueSize: number;
|
|
10
|
+
debug: (message: string) => void;
|
|
11
|
+
/** Called once when the server refuses the key; retrying could not help. */
|
|
12
|
+
onStop: (message: string) => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Batches events and posts them to the ingest API. It never throws into the page
|
|
16
|
+
* and never blocks it: a Midline outage costs queued telemetry, not the app.
|
|
17
|
+
*/
|
|
18
|
+
export declare class BrowserTransport {
|
|
19
|
+
private readonly options;
|
|
20
|
+
private queue;
|
|
21
|
+
private timer;
|
|
22
|
+
private sending;
|
|
23
|
+
private stopped;
|
|
24
|
+
private retryAt;
|
|
25
|
+
private backoffMs;
|
|
26
|
+
dropped: number;
|
|
27
|
+
constructor(options: TransportOptions);
|
|
28
|
+
start(): void;
|
|
29
|
+
enqueue(event: BrowserEvent): void;
|
|
30
|
+
get size(): number;
|
|
31
|
+
/** Sends everything queued, one batch at a time, unless Midline asked us to back off. */
|
|
32
|
+
flush(): Promise<void>;
|
|
33
|
+
/**
|
|
34
|
+
* The page is being hidden and may never come back. Hands whatever is queued to
|
|
35
|
+
* keepalive requests without waiting on them; an in-flight regular flush is left
|
|
36
|
+
* alone, since its batch already left the queue.
|
|
37
|
+
*/
|
|
38
|
+
flushOnHide(): void;
|
|
39
|
+
stop(): void;
|
|
40
|
+
private trim;
|
|
41
|
+
private send;
|
|
42
|
+
private backOff;
|
|
43
|
+
}
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.BrowserTransport = void 0;
|
|
4
|
+
/** Browsers cap the combined body of in-flight keepalive requests at 64 KiB. */
|
|
5
|
+
const KEEPALIVE_BYTES = 60000;
|
|
6
|
+
const MAX_RETRY_DELAY_MS = 300000;
|
|
7
|
+
/**
|
|
8
|
+
* Batches events and posts them to the ingest API. It never throws into the page
|
|
9
|
+
* and never blocks it: a Midline outage costs queued telemetry, not the app.
|
|
10
|
+
*/
|
|
11
|
+
class BrowserTransport {
|
|
12
|
+
constructor(options) {
|
|
13
|
+
this.options = options;
|
|
14
|
+
this.queue = [];
|
|
15
|
+
this.sending = false;
|
|
16
|
+
this.stopped = false;
|
|
17
|
+
this.retryAt = 0;
|
|
18
|
+
this.backoffMs = 0;
|
|
19
|
+
this.dropped = 0;
|
|
20
|
+
}
|
|
21
|
+
start() {
|
|
22
|
+
if (this.timer || this.stopped)
|
|
23
|
+
return;
|
|
24
|
+
this.timer = setInterval(() => void this.flush(), this.options.flushIntervalMs);
|
|
25
|
+
}
|
|
26
|
+
enqueue(event) {
|
|
27
|
+
if (this.stopped)
|
|
28
|
+
return;
|
|
29
|
+
this.queue.push(event);
|
|
30
|
+
this.trim();
|
|
31
|
+
if (this.queue.length >= this.options.maxBatchSize && Date.now() >= this.retryAt) {
|
|
32
|
+
void this.flush();
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
get size() {
|
|
36
|
+
return this.queue.length;
|
|
37
|
+
}
|
|
38
|
+
/** Sends everything queued, one batch at a time, unless Midline asked us to back off. */
|
|
39
|
+
async flush() {
|
|
40
|
+
if (this.stopped || this.sending || !this.queue.length || Date.now() < this.retryAt)
|
|
41
|
+
return;
|
|
42
|
+
this.sending = true;
|
|
43
|
+
try {
|
|
44
|
+
while (this.queue.length && !this.stopped) {
|
|
45
|
+
const batch = this.queue.splice(0, this.options.maxBatchSize);
|
|
46
|
+
const outcome = await this.send(batch, false);
|
|
47
|
+
if (outcome === "retry") {
|
|
48
|
+
this.queue.unshift(...batch);
|
|
49
|
+
this.trim();
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (outcome === "stop")
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
finally {
|
|
57
|
+
this.sending = false;
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* The page is being hidden and may never come back. Hands whatever is queued to
|
|
62
|
+
* keepalive requests without waiting on them; an in-flight regular flush is left
|
|
63
|
+
* alone, since its batch already left the queue.
|
|
64
|
+
*/
|
|
65
|
+
flushOnHide() {
|
|
66
|
+
if (this.stopped || !this.queue.length)
|
|
67
|
+
return;
|
|
68
|
+
let budget = KEEPALIVE_BYTES;
|
|
69
|
+
while (this.queue.length) {
|
|
70
|
+
const batch = [];
|
|
71
|
+
let bytes = 12;
|
|
72
|
+
while (this.queue.length && batch.length < this.options.maxBatchSize) {
|
|
73
|
+
const size = JSON.stringify(this.queue[0]).length + 1;
|
|
74
|
+
if (batch.length && bytes + size > budget)
|
|
75
|
+
break;
|
|
76
|
+
batch.push(this.queue.shift());
|
|
77
|
+
bytes += size;
|
|
78
|
+
}
|
|
79
|
+
budget -= bytes;
|
|
80
|
+
void this.send(batch, budget > 0);
|
|
81
|
+
if (budget <= 0)
|
|
82
|
+
break;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
stop() {
|
|
86
|
+
this.stopped = true;
|
|
87
|
+
this.queue = [];
|
|
88
|
+
if (this.timer)
|
|
89
|
+
clearInterval(this.timer);
|
|
90
|
+
this.timer = undefined;
|
|
91
|
+
}
|
|
92
|
+
trim() {
|
|
93
|
+
const excess = this.queue.length - this.options.maxQueueSize;
|
|
94
|
+
if (excess > 0) {
|
|
95
|
+
this.queue.splice(0, excess);
|
|
96
|
+
this.dropped += excess;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
async send(batch, keepalive) {
|
|
100
|
+
let response;
|
|
101
|
+
try {
|
|
102
|
+
response = await this.options.fetch(this.options.batchUrl, {
|
|
103
|
+
method: "POST",
|
|
104
|
+
headers: { "Content-Type": "application/json", "X-API-Key": this.options.apiKey },
|
|
105
|
+
body: JSON.stringify({ events: batch }),
|
|
106
|
+
keepalive,
|
|
107
|
+
credentials: "omit",
|
|
108
|
+
mode: "cors",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
catch (error) {
|
|
112
|
+
// Offline, blocked by an extension, or a server that doesn't answer CORS.
|
|
113
|
+
this.options.debug(`midline: could not reach Midline (${describe(error)}); will retry.`);
|
|
114
|
+
this.backOff();
|
|
115
|
+
return "retry";
|
|
116
|
+
}
|
|
117
|
+
const { status } = response;
|
|
118
|
+
if (status >= 200 && status < 300) {
|
|
119
|
+
this.backoffMs = 0;
|
|
120
|
+
this.retryAt = 0;
|
|
121
|
+
return "ok";
|
|
122
|
+
}
|
|
123
|
+
if (status === 401 || status === 403) {
|
|
124
|
+
const detail = await serverMessage(response);
|
|
125
|
+
this.stop();
|
|
126
|
+
this.options.onStop(`midline: the Midline server refused this key (HTTP ${status}${detail ? `: ${detail}` : ""}). ` +
|
|
127
|
+
"Browser monitoring is now off.");
|
|
128
|
+
return "stop";
|
|
129
|
+
}
|
|
130
|
+
if (status === 408 || status === 429 || status >= 500) {
|
|
131
|
+
this.backOff(parseRetryAfter(response.headers.get("retry-after")));
|
|
132
|
+
this.options.debug(`midline: Midline answered HTTP ${status}; events are kept and retried.`);
|
|
133
|
+
return "retry";
|
|
134
|
+
}
|
|
135
|
+
this.dropped += batch.length;
|
|
136
|
+
const detail = await serverMessage(response);
|
|
137
|
+
this.options.debug(`midline: Midline rejected ${batch.length} events (HTTP ${status}${detail ? `: ${detail}` : ""}).`);
|
|
138
|
+
return "drop";
|
|
139
|
+
}
|
|
140
|
+
backOff(retryAfterMs = 0) {
|
|
141
|
+
this.backoffMs = Math.min(Math.max(this.backoffMs * 2, 1000), MAX_RETRY_DELAY_MS);
|
|
142
|
+
this.retryAt = Date.now() + Math.max(this.backoffMs, Math.min(retryAfterMs, MAX_RETRY_DELAY_MS));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
exports.BrowserTransport = BrowserTransport;
|
|
146
|
+
function parseRetryAfter(value) {
|
|
147
|
+
if (!value)
|
|
148
|
+
return 0;
|
|
149
|
+
const seconds = Number(value);
|
|
150
|
+
if (Number.isFinite(seconds))
|
|
151
|
+
return Math.max(0, seconds * 1000);
|
|
152
|
+
const date = Date.parse(value);
|
|
153
|
+
return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0;
|
|
154
|
+
}
|
|
155
|
+
async function serverMessage(response) {
|
|
156
|
+
try {
|
|
157
|
+
const text = await response.text();
|
|
158
|
+
const parsed = JSON.parse(text);
|
|
159
|
+
const message = Array.isArray(parsed?.message) ? parsed.message.join("; ") : parsed?.message;
|
|
160
|
+
return typeof message === "string" ? message.slice(0, 300) : "";
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return "";
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
function describe(error) {
|
|
167
|
+
return error instanceof Error ? error.message : String(error);
|
|
168
|
+
}
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import type { EventCategory, EventSeverity } from "../types.js";
|
|
2
|
+
/** Which fetch/XHR calls become events. Every call is a breadcrumb regardless. */
|
|
3
|
+
export type RequestCapture = "failed" | "all" | false;
|
|
4
|
+
export type ConsoleLevel = "error" | "warn" | "info" | "log" | "debug";
|
|
5
|
+
/** Kept deliberately small: an id is enough to find a user's sessions, and anything more is personal data. */
|
|
6
|
+
export interface MidlineUser {
|
|
7
|
+
id?: string;
|
|
8
|
+
username?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface MidlineBrowserConfig {
|
|
11
|
+
/**
|
|
12
|
+
* A **browser key** (`pk_…`) from the Midline dashboard. Browser keys are public
|
|
13
|
+
* and only accepted from the origins listed on the key. Server keys (`ak_…`) are
|
|
14
|
+
* secrets; the SDK refuses to start with one, and the server refuses them from
|
|
15
|
+
* browsers anyway.
|
|
16
|
+
*/
|
|
17
|
+
apiKey: string;
|
|
18
|
+
/** The Midline server. Default `https://api.usemidline.com`. `http://` only for localhost. */
|
|
19
|
+
endpoint?: string;
|
|
20
|
+
/** Name for this app, e.g. `checkout-web`. */
|
|
21
|
+
service?: string;
|
|
22
|
+
environment?: string;
|
|
23
|
+
/** Build or version tag, so an error can be tied to the deploy that introduced it. */
|
|
24
|
+
release?: string;
|
|
25
|
+
/** Set to false to keep the SDK inert (e.g. in local development). */
|
|
26
|
+
enabled?: boolean;
|
|
27
|
+
/** Uncaught errors and unhandled promise rejections. Default true. */
|
|
28
|
+
captureErrors?: boolean;
|
|
29
|
+
/** fetch and XMLHttpRequest calls. Default `"failed"`: 4xx, 5xx and network errors. */
|
|
30
|
+
captureRequests?: RequestCapture;
|
|
31
|
+
/**
|
|
32
|
+
* Also send console output as `console` events. `true` means `["error", "warn"]`.
|
|
33
|
+
* Default false. Wrapping console makes devtools attribute log lines to the SDK,
|
|
34
|
+
* which is why it is opt-in.
|
|
35
|
+
*/
|
|
36
|
+
captureConsole?: boolean | ConsoleLevel[];
|
|
37
|
+
/** LCP, INP, CLS, FCP and TTFB, reported once when the page is first hidden. Default true. */
|
|
38
|
+
captureWebVitals?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* Requests that get a W3C `traceparent` header, so the backend's midline-agent
|
|
41
|
+
* links its request to this page. Strings match as URL prefixes (or path prefixes
|
|
42
|
+
* when they start with `/`), RegExps against the full URL. Default: same-origin
|
|
43
|
+
* requests only, because a new header on a cross-origin call makes that API
|
|
44
|
+
* answer a CORS preflight.
|
|
45
|
+
*/
|
|
46
|
+
tracePropagationTargets?: Array<string | RegExp>;
|
|
47
|
+
/** Error messages to drop. Strings match as substrings. */
|
|
48
|
+
ignoreErrors?: Array<string | RegExp>;
|
|
49
|
+
/** Request URLs never recorded, e.g. analytics beacons. Strings match as substrings. */
|
|
50
|
+
ignoreUrls?: Array<string | RegExp>;
|
|
51
|
+
/** Fraction of events sent, 0–1. Default 1. */
|
|
52
|
+
sampleRate?: number;
|
|
53
|
+
/** Ceiling on events per minute, so an error in a render loop can't flood the project. Default 120. */
|
|
54
|
+
maxEventsPerMinute?: number;
|
|
55
|
+
/** Extra field names to redact, on top of the built-in list. */
|
|
56
|
+
redactFields?: string[];
|
|
57
|
+
/**
|
|
58
|
+
* Last look at every event. Return null to drop it. Change `payload` and
|
|
59
|
+
* `metadata` freely; other unknown top-level fields are removed, because the
|
|
60
|
+
* server rejects them.
|
|
61
|
+
*/
|
|
62
|
+
beforeSend?: (event: BrowserEvent) => BrowserEvent | null | undefined;
|
|
63
|
+
/** Log the SDK's own diagnostics to the console. */
|
|
64
|
+
debug?: boolean;
|
|
65
|
+
/** How often queued events are sent, in ms. Default 5000. */
|
|
66
|
+
flushIntervalMs?: number;
|
|
67
|
+
/** Events per request. Default 20. */
|
|
68
|
+
maxBatchSize?: number;
|
|
69
|
+
/** Events held while Midline is unreachable; oldest dropped past this. Default 200. */
|
|
70
|
+
maxQueueSize?: number;
|
|
71
|
+
}
|
|
72
|
+
/** An event as it is sent to the ingest API. */
|
|
73
|
+
export interface BrowserEvent {
|
|
74
|
+
eventType: "error" | "request" | "console" | "performance" | "custom";
|
|
75
|
+
route: string;
|
|
76
|
+
method?: string;
|
|
77
|
+
statusCode?: number;
|
|
78
|
+
responseTime?: number;
|
|
79
|
+
severity: EventSeverity;
|
|
80
|
+
category?: EventCategory;
|
|
81
|
+
timestamp: string;
|
|
82
|
+
service?: string;
|
|
83
|
+
environment?: string;
|
|
84
|
+
release?: string;
|
|
85
|
+
userAgent?: string;
|
|
86
|
+
traceId?: string;
|
|
87
|
+
spanId?: string;
|
|
88
|
+
payload?: Record<string, unknown>;
|
|
89
|
+
metadata: Record<string, unknown>;
|
|
90
|
+
}
|
|
91
|
+
export interface CaptureContext {
|
|
92
|
+
tags?: Record<string, string>;
|
|
93
|
+
extra?: Record<string, unknown>;
|
|
94
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export type VitalName = "LCP" | "INP" | "CLS" | "FCP" | "TTFB";
|
|
2
|
+
export type VitalRating = "good" | "needs-improvement" | "poor";
|
|
3
|
+
export interface VitalsReport {
|
|
4
|
+
vitals: Partial<Record<VitalName, {
|
|
5
|
+
value: number;
|
|
6
|
+
rating: VitalRating;
|
|
7
|
+
}>>;
|
|
8
|
+
navigationType?: string;
|
|
9
|
+
}
|
|
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 declare function observeVitals(win: Window, onReport: (report: VitalsReport) => void): () => void;
|