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,601 @@
|
|
|
1
|
+
import { Redactor } from "../redact.js";
|
|
2
|
+
import { instrumentConsole, instrumentErrors, instrumentFetch, instrumentHistory, instrumentXhr, } from "./instrument.js";
|
|
3
|
+
import { BrowserTransport } from "./transport.js";
|
|
4
|
+
import { BROWSER_SDK_VERSION } from "./version.js";
|
|
5
|
+
import { observeVitals } from "./vitals.js";
|
|
6
|
+
/** Same default as the Node agent (src/config.ts), repeated so this bundle stays free of Node code. */
|
|
7
|
+
const DEFAULT_ENDPOINT = "https://api.usemidline.com";
|
|
8
|
+
const INGEST_PATH = "/api/api-monitor/events";
|
|
9
|
+
const MAX_BREADCRUMBS = 50;
|
|
10
|
+
const MAX_EVENT_CHARS = 60000;
|
|
11
|
+
const DUPLICATE_WINDOW_MS = 5000;
|
|
12
|
+
/** Matches the Node agent's console line cap. */
|
|
13
|
+
const MAX_CONSOLE_CHARS = 4096;
|
|
14
|
+
const CONSOLE_SEVERITY = {
|
|
15
|
+
error: "high",
|
|
16
|
+
warn: "medium",
|
|
17
|
+
info: "low",
|
|
18
|
+
log: "low",
|
|
19
|
+
debug: "low",
|
|
20
|
+
};
|
|
21
|
+
/** Top-level fields the ingest API accepts from this SDK; it rejects the whole batch on anything else. */
|
|
22
|
+
const WIRE_FIELDS = new Set([
|
|
23
|
+
"eventType", "route", "method", "statusCode", "responseTime", "severity", "category", "timestamp",
|
|
24
|
+
"service", "environment", "release", "userAgent", "traceId", "spanId", "payload", "metadata",
|
|
25
|
+
]);
|
|
26
|
+
export class BrowserConfigError extends Error {
|
|
27
|
+
}
|
|
28
|
+
/** Accepts a base URL or the ingest URL, and returns the batch URL. */
|
|
29
|
+
export function resolveBatchUrl(endpoint = DEFAULT_ENDPOINT) {
|
|
30
|
+
let url;
|
|
31
|
+
try {
|
|
32
|
+
url = new URL(endpoint);
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
throw new BrowserConfigError(`endpoint "${endpoint}" is not a valid URL`);
|
|
36
|
+
}
|
|
37
|
+
if (url.username || url.password) {
|
|
38
|
+
throw new BrowserConfigError("endpoint must not contain credentials");
|
|
39
|
+
}
|
|
40
|
+
const loopback = url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]";
|
|
41
|
+
if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
|
|
42
|
+
throw new BrowserConfigError(`endpoint must use https:// (http:// is only allowed for localhost)`);
|
|
43
|
+
}
|
|
44
|
+
let path = url.pathname.replace(/\/+$/, "");
|
|
45
|
+
if (path.endsWith(`${INGEST_PATH}/batch`)) {
|
|
46
|
+
// already the batch URL
|
|
47
|
+
}
|
|
48
|
+
else if (path.endsWith(INGEST_PATH)) {
|
|
49
|
+
path += "/batch";
|
|
50
|
+
}
|
|
51
|
+
else {
|
|
52
|
+
path += `${INGEST_PATH}/batch`;
|
|
53
|
+
}
|
|
54
|
+
return `${url.origin}${path}`;
|
|
55
|
+
}
|
|
56
|
+
function resolve(config) {
|
|
57
|
+
const apiKey = typeof config.apiKey === "string" ? config.apiKey.trim() : "";
|
|
58
|
+
if (!apiKey) {
|
|
59
|
+
throw new BrowserConfigError("apiKey is required: create a browser key (pk_…) in the Midline dashboard");
|
|
60
|
+
}
|
|
61
|
+
if (apiKey.startsWith("ak_")) {
|
|
62
|
+
throw new BrowserConfigError("this is a server key (ak_…). Server keys are secrets: anyone can read a key shipped to a browser, " +
|
|
63
|
+
"and the Midline server refuses them from browsers. Create a browser key (pk_…) for this app.");
|
|
64
|
+
}
|
|
65
|
+
const batchUrl = resolveBatchUrl(config.endpoint);
|
|
66
|
+
const levels = config.captureConsole === true ? ["error", "warn"] : Array.isArray(config.captureConsole) ? config.captureConsole : [];
|
|
67
|
+
return {
|
|
68
|
+
apiKey,
|
|
69
|
+
batchUrl,
|
|
70
|
+
ingestPrefix: batchUrl.slice(0, batchUrl.length - "/batch".length),
|
|
71
|
+
service: clamp(config.service, 128),
|
|
72
|
+
environment: clamp(config.environment, 128),
|
|
73
|
+
release: clamp(config.release, 128),
|
|
74
|
+
captureErrors: config.captureErrors !== false,
|
|
75
|
+
captureRequests: config.captureRequests === undefined ? "failed" : config.captureRequests,
|
|
76
|
+
consoleLevels: levels.filter((level) => level in CONSOLE_SEVERITY),
|
|
77
|
+
captureWebVitals: config.captureWebVitals !== false,
|
|
78
|
+
tracePropagationTargets: config.tracePropagationTargets,
|
|
79
|
+
ignoreErrors: config.ignoreErrors ?? [],
|
|
80
|
+
ignoreUrls: config.ignoreUrls ?? [],
|
|
81
|
+
sampleRate: clampNumber(config.sampleRate, 0, 1, 1),
|
|
82
|
+
maxEventsPerMinute: clampNumber(config.maxEventsPerMinute, 1, 10000, 120),
|
|
83
|
+
beforeSend: config.beforeSend,
|
|
84
|
+
debug: config.debug === true,
|
|
85
|
+
flushIntervalMs: clampNumber(config.flushIntervalMs, 250, 60000, 5000),
|
|
86
|
+
maxBatchSize: Math.round(clampNumber(config.maxBatchSize, 1, 100, 20)),
|
|
87
|
+
maxQueueSize: Math.round(clampNumber(config.maxQueueSize, 1, 5000, 200)),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* One installed SDK instance. Everything it does to the page — wrapped fetch,
|
|
92
|
+
* XHR, console and history, window listeners — is undone by `close()`.
|
|
93
|
+
*/
|
|
94
|
+
export class BrowserClient {
|
|
95
|
+
constructor(win, config, redactFields) {
|
|
96
|
+
this.win = win;
|
|
97
|
+
this.config = config;
|
|
98
|
+
this.teardowns = [];
|
|
99
|
+
this.breadcrumbs = [];
|
|
100
|
+
this.recentErrors = new Map();
|
|
101
|
+
this.sessionId = sessionIdFor();
|
|
102
|
+
this.traceId = randomHex(16);
|
|
103
|
+
this.tags = {};
|
|
104
|
+
this.lastRefill = Date.now();
|
|
105
|
+
this.active = true;
|
|
106
|
+
this.inConsoleHook = false;
|
|
107
|
+
this.rateLimitNoted = false;
|
|
108
|
+
this.redactor = new Redactor(redactFields);
|
|
109
|
+
this.tokens = config.maxEventsPerMinute;
|
|
110
|
+
const console = consoleOf(win);
|
|
111
|
+
this.originalConsole = { warn: console.warn.bind(console), log: console.log.bind(console) };
|
|
112
|
+
const originalFetch = win.fetch;
|
|
113
|
+
this.transport = new BrowserTransport({
|
|
114
|
+
batchUrl: config.batchUrl,
|
|
115
|
+
apiKey: config.apiKey,
|
|
116
|
+
// Called with the window as `this`: a detached fetch throws "Illegal invocation".
|
|
117
|
+
fetch: (input, init) => originalFetch.call(win, input, init),
|
|
118
|
+
flushIntervalMs: config.flushIntervalMs,
|
|
119
|
+
maxBatchSize: config.maxBatchSize,
|
|
120
|
+
maxQueueSize: config.maxQueueSize,
|
|
121
|
+
debug: (message) => this.debug(message),
|
|
122
|
+
onStop: (message) => {
|
|
123
|
+
this.active = false;
|
|
124
|
+
this.originalConsole.warn(message);
|
|
125
|
+
},
|
|
126
|
+
});
|
|
127
|
+
this.install(originalFetch);
|
|
128
|
+
}
|
|
129
|
+
/** Returns undefined, after one console warning, when the SDK can't run. It never throws into the app. */
|
|
130
|
+
static create(config) {
|
|
131
|
+
const win = typeof window === "undefined" ? undefined : window;
|
|
132
|
+
if (!win || typeof win.fetch !== "function") {
|
|
133
|
+
// Server-side rendering, a worker, or a browser too old to matter.
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
if (config?.enabled === false)
|
|
137
|
+
return undefined;
|
|
138
|
+
try {
|
|
139
|
+
return new BrowserClient(win, resolve(config), config.redactFields ?? []);
|
|
140
|
+
}
|
|
141
|
+
catch (error) {
|
|
142
|
+
consoleOf(win)?.warn?.(`midline: browser monitoring is off — ${error instanceof Error ? error.message : String(error)}`);
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
install(originalFetch) {
|
|
147
|
+
const hooks = {
|
|
148
|
+
onError: (error, mechanism, location) => this.handleError(error, mechanism, location),
|
|
149
|
+
onRequest: (record) => this.handleRequest(record),
|
|
150
|
+
onConsole: (level, args) => this.handleConsole(level, args),
|
|
151
|
+
onNavigation: (from, to) => this.handleNavigation(from, to),
|
|
152
|
+
propagation: (url) => this.propagation(url),
|
|
153
|
+
isOwnRequest: (url) => url.href.startsWith(this.config.ingestPrefix),
|
|
154
|
+
};
|
|
155
|
+
if (this.config.captureErrors)
|
|
156
|
+
this.teardowns.push(instrumentErrors(this.win, hooks));
|
|
157
|
+
this.teardowns.push(instrumentFetch(this.win, originalFetch, hooks));
|
|
158
|
+
this.teardowns.push(instrumentXhr(this.win, hooks));
|
|
159
|
+
this.teardowns.push(instrumentHistory(this.win, hooks));
|
|
160
|
+
if (this.config.consoleLevels.length) {
|
|
161
|
+
this.teardowns.push(instrumentConsole(consoleOf(this.win), this.config.consoleLevels, hooks));
|
|
162
|
+
}
|
|
163
|
+
if (this.config.captureWebVitals) {
|
|
164
|
+
this.teardowns.push(observeVitals(this.win, (report) => this.handleVitals(report)));
|
|
165
|
+
}
|
|
166
|
+
// Registered after the vitals listener so its event is queued before this sends.
|
|
167
|
+
const onHide = () => {
|
|
168
|
+
if (this.win.document?.visibilityState === "hidden")
|
|
169
|
+
this.transport.flushOnHide();
|
|
170
|
+
};
|
|
171
|
+
const onPageHide = () => this.transport.flushOnHide();
|
|
172
|
+
this.win.document?.addEventListener("visibilitychange", onHide, true);
|
|
173
|
+
this.win.addEventListener("pagehide", onPageHide, true);
|
|
174
|
+
this.teardowns.push(() => {
|
|
175
|
+
this.win.document?.removeEventListener("visibilitychange", onHide, true);
|
|
176
|
+
this.win.removeEventListener("pagehide", onPageHide, true);
|
|
177
|
+
});
|
|
178
|
+
this.transport.start();
|
|
179
|
+
this.debug(`midline: browser monitoring on, sending to ${this.config.batchUrl}`);
|
|
180
|
+
}
|
|
181
|
+
// ---- public API -------------------------------------------------------------
|
|
182
|
+
captureException(error, context) {
|
|
183
|
+
this.guard(() => this.handleError(error, "manual", undefined, context));
|
|
184
|
+
}
|
|
185
|
+
captureMessage(message, severity = "low", context) {
|
|
186
|
+
this.guard(() => {
|
|
187
|
+
const event = this.base("custom", this.currentRoute(), severity, "application");
|
|
188
|
+
event.payload = {
|
|
189
|
+
message: this.redactor.string(String(message), 1024),
|
|
190
|
+
...this.contextPayload(context),
|
|
191
|
+
};
|
|
192
|
+
this.applyTags(event, context);
|
|
193
|
+
this.emit(event);
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
setUser(user) {
|
|
197
|
+
this.user = user
|
|
198
|
+
? {
|
|
199
|
+
id: user.id === undefined ? undefined : String(user.id).slice(0, 128),
|
|
200
|
+
username: user.username === undefined ? undefined : String(user.username).slice(0, 128),
|
|
201
|
+
}
|
|
202
|
+
: undefined;
|
|
203
|
+
}
|
|
204
|
+
setTag(key, value) {
|
|
205
|
+
if (Object.keys(this.tags).length >= 50 && !(key in this.tags))
|
|
206
|
+
return;
|
|
207
|
+
this.tags[String(key).slice(0, 64)] = String(value).slice(0, 256);
|
|
208
|
+
}
|
|
209
|
+
addBreadcrumb(message, type = "manual") {
|
|
210
|
+
this.guard(() => this.breadcrumb(type, message));
|
|
211
|
+
}
|
|
212
|
+
flush() {
|
|
213
|
+
return this.transport.flush().catch(() => undefined);
|
|
214
|
+
}
|
|
215
|
+
async close() {
|
|
216
|
+
for (const teardown of this.teardowns.splice(0).reverse()) {
|
|
217
|
+
try {
|
|
218
|
+
teardown();
|
|
219
|
+
}
|
|
220
|
+
catch {
|
|
221
|
+
// keep tearing down the rest
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
await this.flush();
|
|
225
|
+
this.active = false;
|
|
226
|
+
this.transport.stop();
|
|
227
|
+
}
|
|
228
|
+
// ---- event sources ----------------------------------------------------------
|
|
229
|
+
handleError(error, mechanism, location, context) {
|
|
230
|
+
const { name, message, stack } = describeError(error, location);
|
|
231
|
+
// A script from another origin without CORS headers reports only this. There
|
|
232
|
+
// is nothing to act on, and it would otherwise group every such error into one.
|
|
233
|
+
if (message === "Script error." && !stack) {
|
|
234
|
+
this.debug("midline: skipped a cross-origin \"Script error.\" (add crossorigin to the script tag to see it)");
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
if (matches(message, this.config.ignoreErrors))
|
|
238
|
+
return;
|
|
239
|
+
const signature = `${message}\n${(stack ?? "").slice(0, 500)}`;
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
const seenAt = this.recentErrors.get(signature);
|
|
242
|
+
if (mechanism !== "manual" && seenAt !== undefined && now - seenAt < DUPLICATE_WINDOW_MS)
|
|
243
|
+
return;
|
|
244
|
+
this.recentErrors.set(signature, now);
|
|
245
|
+
if (this.recentErrors.size > 100) {
|
|
246
|
+
this.recentErrors.delete(this.recentErrors.keys().next().value);
|
|
247
|
+
}
|
|
248
|
+
const event = this.base("error", this.currentRoute(), "high", "application");
|
|
249
|
+
event.payload = {
|
|
250
|
+
error: this.redactor.string(message, 1024),
|
|
251
|
+
type: name,
|
|
252
|
+
stack: stack ? this.redactor.string(stack, 16 * 1024) : undefined,
|
|
253
|
+
mechanism,
|
|
254
|
+
breadcrumbs: this.breadcrumbs.slice(-20),
|
|
255
|
+
context: {
|
|
256
|
+
url: this.pageUrl(),
|
|
257
|
+
filename: location?.filename ? this.redactor.string(stripQuery(location.filename), 1024) : undefined,
|
|
258
|
+
line: location?.lineno || undefined,
|
|
259
|
+
column: location?.colno || undefined,
|
|
260
|
+
},
|
|
261
|
+
...this.contextPayload(context),
|
|
262
|
+
};
|
|
263
|
+
this.applyTags(event, context);
|
|
264
|
+
this.emit(event);
|
|
265
|
+
this.breadcrumb("error", `${name}: ${message}`);
|
|
266
|
+
}
|
|
267
|
+
handleRequest(record) {
|
|
268
|
+
const { url } = record;
|
|
269
|
+
const sameOrigin = url.origin === this.win.location.origin;
|
|
270
|
+
const target = sameOrigin ? url.pathname : `${url.origin}${url.pathname}`;
|
|
271
|
+
const outcome = record.status ?? (record.aborted ? "aborted" : "failed");
|
|
272
|
+
this.breadcrumb(record.kind, `${record.method} ${target} ${outcome}`);
|
|
273
|
+
if (record.aborted || matches(url.href, this.config.ignoreUrls))
|
|
274
|
+
return;
|
|
275
|
+
const networkError = record.status === undefined;
|
|
276
|
+
const failed = networkError || record.status >= 400;
|
|
277
|
+
if (this.config.captureRequests === false || (this.config.captureRequests === "failed" && !failed))
|
|
278
|
+
return;
|
|
279
|
+
const severity = networkError || record.status >= 500 ? "high" : record.status >= 400 ? "medium" : "low";
|
|
280
|
+
const event = this.base(networkError ? "error" : "request", url.pathname, severity, networkError ? "infrastructure" : "application");
|
|
281
|
+
event.method = record.method.slice(0, 16);
|
|
282
|
+
if (!networkError && record.status >= 100 && record.status <= 599)
|
|
283
|
+
event.statusCode = record.status;
|
|
284
|
+
event.responseTime = Math.min(Math.max(0, Math.round(record.durationMs)), 86400000);
|
|
285
|
+
event.spanId = record.spanId;
|
|
286
|
+
event.payload = {
|
|
287
|
+
request: {
|
|
288
|
+
url: this.redactor.string(`${url.origin}${url.pathname}`, 2048),
|
|
289
|
+
query: url.search ? this.redactor.query(url.search) : undefined,
|
|
290
|
+
crossOrigin: !sameOrigin,
|
|
291
|
+
via: record.kind,
|
|
292
|
+
},
|
|
293
|
+
...(networkError
|
|
294
|
+
? {
|
|
295
|
+
error: `Network request failed: ${record.method} ${this.redactor.string(target, 512)}`,
|
|
296
|
+
code: "NETWORK_ERROR",
|
|
297
|
+
breadcrumbs: this.breadcrumbs.slice(-20),
|
|
298
|
+
}
|
|
299
|
+
: {}),
|
|
300
|
+
};
|
|
301
|
+
this.emit(event);
|
|
302
|
+
}
|
|
303
|
+
handleConsole(level, args) {
|
|
304
|
+
// Anything this SDK prints must not loop back in as an event.
|
|
305
|
+
if (this.inConsoleHook)
|
|
306
|
+
return;
|
|
307
|
+
this.inConsoleHook = true;
|
|
308
|
+
try {
|
|
309
|
+
const line = formatConsoleArgs(args);
|
|
310
|
+
this.breadcrumb("console", `[${level}] ${line.slice(0, 256)}`);
|
|
311
|
+
const event = this.base("console", this.currentRoute(), CONSOLE_SEVERITY[level]);
|
|
312
|
+
event.payload = { message: this.redactor.string(line, MAX_CONSOLE_CHARS) };
|
|
313
|
+
event.metadata.level = level;
|
|
314
|
+
this.emit(event);
|
|
315
|
+
}
|
|
316
|
+
finally {
|
|
317
|
+
this.inConsoleHook = false;
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
handleNavigation(from, to) {
|
|
321
|
+
// A new view is a new trace, so its API calls don't blur into the last one's.
|
|
322
|
+
this.traceId = randomHex(16);
|
|
323
|
+
this.breadcrumb("navigation", `${pathOf(from)} -> ${pathOf(to)}`);
|
|
324
|
+
}
|
|
325
|
+
handleVitals(report) {
|
|
326
|
+
const poor = Object.values(report.vitals).some((vital) => vital?.rating === "poor");
|
|
327
|
+
const event = this.base("performance", this.currentRoute(), poor ? "medium" : "low", "performance");
|
|
328
|
+
event.payload = { vitals: report.vitals, navigationType: report.navigationType };
|
|
329
|
+
this.emit(event);
|
|
330
|
+
}
|
|
331
|
+
// ---- plumbing -----------------------------------------------------------------
|
|
332
|
+
propagation(url) {
|
|
333
|
+
const targets = this.config.tracePropagationTargets;
|
|
334
|
+
const pageOrigin = this.win.location.origin;
|
|
335
|
+
const allowed = targets
|
|
336
|
+
? targets.some((target) => typeof target === "string"
|
|
337
|
+
? target.startsWith("/")
|
|
338
|
+
? url.origin === pageOrigin && url.pathname.startsWith(target)
|
|
339
|
+
: url.href.startsWith(target)
|
|
340
|
+
: safeTest(target, url.href))
|
|
341
|
+
: url.origin === pageOrigin;
|
|
342
|
+
if (!allowed)
|
|
343
|
+
return undefined;
|
|
344
|
+
const spanId = randomHex(8);
|
|
345
|
+
return { headers: { traceparent: `00-${this.traceId}-${spanId}-01` }, spanId };
|
|
346
|
+
}
|
|
347
|
+
base(eventType, route, severity, category) {
|
|
348
|
+
const metadata = {
|
|
349
|
+
source: "sdk",
|
|
350
|
+
sdk: "midline-agent/browser",
|
|
351
|
+
version: BROWSER_SDK_VERSION,
|
|
352
|
+
runtime: "browser",
|
|
353
|
+
sessionId: this.sessionId,
|
|
354
|
+
page: { url: this.pageUrl(), path: this.currentRoute() },
|
|
355
|
+
};
|
|
356
|
+
if (this.user && (this.user.id || this.user.username))
|
|
357
|
+
metadata.user = { ...this.user };
|
|
358
|
+
if (Object.keys(this.tags).length)
|
|
359
|
+
metadata.tags = { ...this.tags };
|
|
360
|
+
const nav = this.win.navigator;
|
|
361
|
+
return {
|
|
362
|
+
eventType,
|
|
363
|
+
route: this.redactor.string(route || "/", 2048),
|
|
364
|
+
severity,
|
|
365
|
+
category,
|
|
366
|
+
timestamp: new Date().toISOString(),
|
|
367
|
+
service: this.config.service,
|
|
368
|
+
environment: this.config.environment,
|
|
369
|
+
release: this.config.release,
|
|
370
|
+
userAgent: clamp(nav?.userAgent, 512),
|
|
371
|
+
traceId: this.traceId,
|
|
372
|
+
metadata,
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
emit(event) {
|
|
376
|
+
if (!this.active)
|
|
377
|
+
return;
|
|
378
|
+
if (this.config.sampleRate < 1 && Math.random() >= this.config.sampleRate)
|
|
379
|
+
return;
|
|
380
|
+
if (!this.takeToken()) {
|
|
381
|
+
if (!this.rateLimitNoted) {
|
|
382
|
+
this.rateLimitNoted = true;
|
|
383
|
+
this.debug(`midline: over ${this.config.maxEventsPerMinute} events a minute; dropping until it calms down.`);
|
|
384
|
+
}
|
|
385
|
+
return;
|
|
386
|
+
}
|
|
387
|
+
let final = event;
|
|
388
|
+
if (this.config.beforeSend) {
|
|
389
|
+
try {
|
|
390
|
+
final = this.config.beforeSend(event);
|
|
391
|
+
}
|
|
392
|
+
catch (error) {
|
|
393
|
+
this.debug(`midline: beforeSend threw (${error instanceof Error ? error.message : String(error)}); sending the event unchanged.`);
|
|
394
|
+
final = event;
|
|
395
|
+
}
|
|
396
|
+
if (!final)
|
|
397
|
+
return;
|
|
398
|
+
}
|
|
399
|
+
const wire = toWire(final);
|
|
400
|
+
if (!wire) {
|
|
401
|
+
this.debug("midline: dropped an event larger than 60 KB.");
|
|
402
|
+
return;
|
|
403
|
+
}
|
|
404
|
+
this.transport.enqueue(wire);
|
|
405
|
+
}
|
|
406
|
+
takeToken() {
|
|
407
|
+
const now = Date.now();
|
|
408
|
+
const perMs = this.config.maxEventsPerMinute / 60000;
|
|
409
|
+
this.tokens = Math.min(this.config.maxEventsPerMinute, this.tokens + (now - this.lastRefill) * perMs);
|
|
410
|
+
this.lastRefill = now;
|
|
411
|
+
if (this.tokens < 1)
|
|
412
|
+
return false;
|
|
413
|
+
this.tokens -= 1;
|
|
414
|
+
this.rateLimitNoted = false;
|
|
415
|
+
return true;
|
|
416
|
+
}
|
|
417
|
+
breadcrumb(type, message) {
|
|
418
|
+
this.breadcrumbs.push({
|
|
419
|
+
type: type.slice(0, 32),
|
|
420
|
+
message: this.redactor.string(String(message), 256),
|
|
421
|
+
timestamp: new Date().toISOString(),
|
|
422
|
+
});
|
|
423
|
+
if (this.breadcrumbs.length > MAX_BREADCRUMBS)
|
|
424
|
+
this.breadcrumbs.shift();
|
|
425
|
+
}
|
|
426
|
+
contextPayload(context) {
|
|
427
|
+
return context?.extra ? { extra: this.redactor.value(context.extra) } : {};
|
|
428
|
+
}
|
|
429
|
+
applyTags(event, context) {
|
|
430
|
+
if (!context?.tags)
|
|
431
|
+
return;
|
|
432
|
+
const tags = { ...(event.metadata.tags ?? {}) };
|
|
433
|
+
for (const [key, value] of Object.entries(context.tags).slice(0, 50)) {
|
|
434
|
+
tags[String(key).slice(0, 64)] = String(value).slice(0, 256);
|
|
435
|
+
}
|
|
436
|
+
event.metadata.tags = tags;
|
|
437
|
+
}
|
|
438
|
+
/** The page path, plus a `#/…` hash route when the app routes by hash. Never the query string. */
|
|
439
|
+
currentRoute() {
|
|
440
|
+
const { pathname, hash } = this.win.location;
|
|
441
|
+
return hash.startsWith("#/") ? `${pathname}${hash.split("?")[0]}` : pathname || "/";
|
|
442
|
+
}
|
|
443
|
+
pageUrl() {
|
|
444
|
+
const { origin, pathname } = this.win.location;
|
|
445
|
+
return this.redactor.string(`${origin}${pathname}`, 2048);
|
|
446
|
+
}
|
|
447
|
+
guard(fn) {
|
|
448
|
+
try {
|
|
449
|
+
fn();
|
|
450
|
+
}
|
|
451
|
+
catch (error) {
|
|
452
|
+
this.debug(`midline: internal error (${error instanceof Error ? error.message : String(error)})`);
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
debug(message) {
|
|
456
|
+
if (this.config.debug)
|
|
457
|
+
this.originalConsole.log(message);
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
/** The DOM typings declare console as a global, not a Window property. */
|
|
461
|
+
function consoleOf(win) {
|
|
462
|
+
return win.console;
|
|
463
|
+
}
|
|
464
|
+
/** Removes empty fields and anything the ingest API would refuse; trims oversized events. */
|
|
465
|
+
function toWire(event) {
|
|
466
|
+
const wire = {};
|
|
467
|
+
for (const [key, value] of Object.entries(event)) {
|
|
468
|
+
if (value !== undefined && WIRE_FIELDS.has(key))
|
|
469
|
+
wire[key] = value;
|
|
470
|
+
}
|
|
471
|
+
const payload = wire.payload;
|
|
472
|
+
if (payload) {
|
|
473
|
+
for (const key of Object.keys(payload)) {
|
|
474
|
+
if (payload[key] === undefined)
|
|
475
|
+
delete payload[key];
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
const size = () => {
|
|
479
|
+
try {
|
|
480
|
+
return JSON.stringify(wire).length;
|
|
481
|
+
}
|
|
482
|
+
catch {
|
|
483
|
+
return Infinity;
|
|
484
|
+
}
|
|
485
|
+
};
|
|
486
|
+
if (size() > MAX_EVENT_CHARS && payload) {
|
|
487
|
+
delete payload.breadcrumbs;
|
|
488
|
+
if (typeof payload.stack === "string")
|
|
489
|
+
payload.stack = payload.stack.slice(0, 2048);
|
|
490
|
+
if (size() > MAX_EVENT_CHARS)
|
|
491
|
+
delete payload.extra;
|
|
492
|
+
}
|
|
493
|
+
return size() > MAX_EVENT_CHARS ? undefined : wire;
|
|
494
|
+
}
|
|
495
|
+
function describeError(error, location) {
|
|
496
|
+
if (error instanceof Error || (typeof error === "object" && error !== null && "message" in error)) {
|
|
497
|
+
const e = error;
|
|
498
|
+
return {
|
|
499
|
+
name: typeof e.name === "string" && e.name ? e.name.slice(0, 128) : "Error",
|
|
500
|
+
message: String(e.message ?? "") || location?.message || "Unknown error",
|
|
501
|
+
stack: typeof e.stack === "string" ? e.stack : undefined,
|
|
502
|
+
};
|
|
503
|
+
}
|
|
504
|
+
if (typeof error === "string") {
|
|
505
|
+
return { name: "Error", message: error || location?.message || "Unknown error" };
|
|
506
|
+
}
|
|
507
|
+
if (error === undefined || error === null) {
|
|
508
|
+
return { name: "Error", message: location?.message || "Unknown error" };
|
|
509
|
+
}
|
|
510
|
+
let text;
|
|
511
|
+
try {
|
|
512
|
+
text = JSON.stringify(error) ?? String(error);
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
text = Object.prototype.toString.call(error);
|
|
516
|
+
}
|
|
517
|
+
return { name: "Error", message: `Non-Error value thrown: ${text.slice(0, 512)}` };
|
|
518
|
+
}
|
|
519
|
+
function formatConsoleArgs(args) {
|
|
520
|
+
const parts = [];
|
|
521
|
+
let length = 0;
|
|
522
|
+
for (const arg of args) {
|
|
523
|
+
let text;
|
|
524
|
+
if (typeof arg === "string")
|
|
525
|
+
text = arg;
|
|
526
|
+
else if (arg instanceof Error)
|
|
527
|
+
text = arg.stack || `${arg.name}: ${arg.message}`;
|
|
528
|
+
else {
|
|
529
|
+
try {
|
|
530
|
+
text = typeof arg === "object" && arg !== null ? JSON.stringify(arg) ?? String(arg) : String(arg);
|
|
531
|
+
}
|
|
532
|
+
catch {
|
|
533
|
+
text = Object.prototype.toString.call(arg);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
parts.push(text);
|
|
537
|
+
length += text.length + 1;
|
|
538
|
+
if (length > MAX_CONSOLE_CHARS * 2)
|
|
539
|
+
break;
|
|
540
|
+
}
|
|
541
|
+
return parts.join(" ");
|
|
542
|
+
}
|
|
543
|
+
function matches(value, patterns) {
|
|
544
|
+
return patterns.some((pattern) => (typeof pattern === "string" ? value.includes(pattern) : safeTest(pattern, value)));
|
|
545
|
+
}
|
|
546
|
+
function safeTest(pattern, value) {
|
|
547
|
+
pattern.lastIndex = 0;
|
|
548
|
+
return pattern.test(value);
|
|
549
|
+
}
|
|
550
|
+
function stripQuery(url) {
|
|
551
|
+
return url.split(/[?#]/)[0];
|
|
552
|
+
}
|
|
553
|
+
function pathOf(href) {
|
|
554
|
+
try {
|
|
555
|
+
const url = new URL(href);
|
|
556
|
+
return url.hash.startsWith("#/") ? `${url.pathname}${url.hash.split("?")[0]}` : url.pathname;
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
return "";
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
function clamp(value, max) {
|
|
563
|
+
if (value === undefined || value === null || value === "")
|
|
564
|
+
return undefined;
|
|
565
|
+
return String(value).slice(0, max);
|
|
566
|
+
}
|
|
567
|
+
function clampNumber(value, min, max, fallback) {
|
|
568
|
+
const n = typeof value === "number" ? value : NaN;
|
|
569
|
+
return Number.isFinite(n) ? Math.min(max, Math.max(min, n)) : fallback;
|
|
570
|
+
}
|
|
571
|
+
/** W3C ids: lowercase hex, never all zeros. */
|
|
572
|
+
export function randomHex(bytes) {
|
|
573
|
+
const buffer = new Uint8Array(bytes);
|
|
574
|
+
const crypto = globalThis.crypto;
|
|
575
|
+
if (crypto && typeof crypto.getRandomValues === "function") {
|
|
576
|
+
crypto.getRandomValues(buffer);
|
|
577
|
+
}
|
|
578
|
+
else {
|
|
579
|
+
for (let i = 0; i < bytes; i++)
|
|
580
|
+
buffer[i] = Math.floor(Math.random() * 256);
|
|
581
|
+
}
|
|
582
|
+
let hex = "";
|
|
583
|
+
for (const byte of buffer)
|
|
584
|
+
hex += byte.toString(16).padStart(2, "0");
|
|
585
|
+
return /^0+$/.test(hex) ? randomHex(bytes) : hex;
|
|
586
|
+
}
|
|
587
|
+
/** Stable for the tab's lifetime; sessionStorage can be unavailable (privacy modes, sandboxed frames). */
|
|
588
|
+
function sessionIdFor() {
|
|
589
|
+
try {
|
|
590
|
+
const storage = globalThis.sessionStorage;
|
|
591
|
+
const existing = storage?.getItem("midline.sid");
|
|
592
|
+
if (existing && /^[a-f0-9]{16,64}$/.test(existing))
|
|
593
|
+
return existing;
|
|
594
|
+
const id = randomHex(16);
|
|
595
|
+
storage?.setItem("midline.sid", id);
|
|
596
|
+
return id;
|
|
597
|
+
}
|
|
598
|
+
catch {
|
|
599
|
+
return randomHex(16);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* midline-agent/browser — error, network and Web Vitals monitoring for web apps.
|
|
3
|
+
*
|
|
4
|
+
* import * as Midline from "midline-agent/browser";
|
|
5
|
+
* Midline.init({ apiKey: "pk_…", service: "checkout-web" });
|
|
6
|
+
*
|
|
7
|
+
* Framework-agnostic: it instruments the page (window errors, fetch, XHR,
|
|
8
|
+
* history), not React, Vue or Angular, so it works under all of them. Call
|
|
9
|
+
* `captureException` from a framework's own error hook for errors it swallows.
|
|
10
|
+
*/
|
|
11
|
+
import { BrowserClient } from "./client.js";
|
|
12
|
+
let client;
|
|
13
|
+
/**
|
|
14
|
+
* Starts monitoring. Safe to call during server-side rendering (it does nothing
|
|
15
|
+
* without a window) and safe to call twice (the first instance is closed).
|
|
16
|
+
* A bad config logs one console warning and leaves the page untouched.
|
|
17
|
+
*/
|
|
18
|
+
export function init(config) {
|
|
19
|
+
if (client)
|
|
20
|
+
void client.close();
|
|
21
|
+
client = BrowserClient.create(config);
|
|
22
|
+
}
|
|
23
|
+
/** Reports an error your code caught, e.g. from a React error boundary. */
|
|
24
|
+
export function captureException(error, context) {
|
|
25
|
+
client?.captureException(error, context);
|
|
26
|
+
}
|
|
27
|
+
export function captureMessage(message, severity, context) {
|
|
28
|
+
client?.captureMessage(message, severity, context);
|
|
29
|
+
}
|
|
30
|
+
/** Attaches a user id to later events. Pass null on sign-out. */
|
|
31
|
+
export function setUser(user) {
|
|
32
|
+
client?.setUser(user);
|
|
33
|
+
}
|
|
34
|
+
export function setTag(key, value) {
|
|
35
|
+
client?.setTag(key, value);
|
|
36
|
+
}
|
|
37
|
+
export function addBreadcrumb(message, type) {
|
|
38
|
+
client?.addBreadcrumb(message, type);
|
|
39
|
+
}
|
|
40
|
+
/** Sends anything queued now. */
|
|
41
|
+
export function flush() {
|
|
42
|
+
return client ? client.flush() : Promise.resolve();
|
|
43
|
+
}
|
|
44
|
+
/** Restores everything the SDK wrapped and sends what's queued. */
|
|
45
|
+
export async function close() {
|
|
46
|
+
const closing = client;
|
|
47
|
+
client = undefined;
|
|
48
|
+
await closing?.close();
|
|
49
|
+
}
|
|
50
|
+
export const Midline = { init, captureException, captureMessage, setUser, setTag, addBreadcrumb, flush, close };
|
|
51
|
+
export { resolveBatchUrl } from "./client.js";
|
|
52
|
+
export { BROWSER_SDK_VERSION } from "./version.js";
|