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