midline-agent 0.4.0 → 0.5.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.
@@ -0,0 +1,58 @@
1
+ /** Mirrors midline-core-api/src/realtime/ingest-protocol.ts's IngestAck. Duplicated,
2
+ * not shared: the two are separate packages, kept in sync by wire convention. */
3
+ export type IngestAckErrorCode = "unauthorized" | "forbidden" | "rate_limited" | "bad_request" | "too_large" | "internal_error";
4
+ export interface IngestAckOk {
5
+ ok: true;
6
+ accepted: number;
7
+ rejected: number;
8
+ }
9
+ export interface IngestAckError {
10
+ ok: false;
11
+ code: IngestAckErrorCode;
12
+ message: string;
13
+ retryAfterMs?: number;
14
+ }
15
+ export type IngestAck = IngestAckOk | IngestAckError;
16
+ export interface SocketTransportOptions {
17
+ apiKey: string;
18
+ /** Full trust store for the Midline endpoint, or undefined for Node's default. */
19
+ ca?: Array<string | Buffer>;
20
+ connectTimeoutMs: number;
21
+ /** Per-emit ack timeout. */
22
+ timeoutMs: number;
23
+ /** Reconnection backoff bounds, derived from the agent's own flush/backoff config (see agent.ts). */
24
+ reconnectionDelayMs: number;
25
+ reconnectionDelayMaxMs: number;
26
+ /** Nothing queued for this long -> disconnect; reconnects lazily on the next send(). */
27
+ idleDisconnectMs: number;
28
+ userAgent: string;
29
+ }
30
+ /** A transport failure with a stable `code`, matching what agent.ts's errorCode()/describe() expect. */
31
+ export declare class TransportError extends Error {
32
+ readonly code: string;
33
+ constructor(message: string, code: string);
34
+ }
35
+ /**
36
+ * Owns one socket.io-client connection to the ingest gateway: connects lazily on
37
+ * first send, lets socket.io-client's own reconnection/backoff handle transport
38
+ * drops, and disconnects after a sustained idle period (autoUnref also unrefs the
39
+ * underlying socket, so — as with the old HTTP transport — telemetry alone never
40
+ * keeps the process alive; the idle disconnect is extra hygiene on top of that,
41
+ * not the only thing standing between this and a hung process).
42
+ */
43
+ export declare class SocketTransport {
44
+ private readonly origin;
45
+ private readonly options;
46
+ private socket;
47
+ private connecting;
48
+ private idleTimer;
49
+ constructor(origin: URL, options: SocketTransportOptions);
50
+ /** Sends one batch, connecting first if needed. Resolves with the server's ack or throws. */
51
+ send(events: Array<Record<string, unknown>>): Promise<IngestAck>;
52
+ destroy(): void;
53
+ private ensureConnected;
54
+ private createSocket;
55
+ private emit;
56
+ private scheduleIdleDisconnect;
57
+ private clearIdleTimer;
58
+ }
@@ -0,0 +1,157 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SocketTransport = exports.TransportError = void 0;
4
+ const socket_io_client_1 = require("socket.io-client");
5
+ const INGEST_NAMESPACE = "/ingest";
6
+ const INGEST_EVENT = "ingest";
7
+ /** A transport failure with a stable `code`, matching what agent.ts's errorCode()/describe() expect. */
8
+ class TransportError extends Error {
9
+ constructor(message, code) {
10
+ super(message);
11
+ this.code = code;
12
+ this.name = "TransportError";
13
+ }
14
+ }
15
+ exports.TransportError = TransportError;
16
+ /**
17
+ * Owns one socket.io-client connection to the ingest gateway: connects lazily on
18
+ * first send, lets socket.io-client's own reconnection/backoff handle transport
19
+ * drops, and disconnects after a sustained idle period (autoUnref also unrefs the
20
+ * underlying socket, so — as with the old HTTP transport — telemetry alone never
21
+ * keeps the process alive; the idle disconnect is extra hygiene on top of that,
22
+ * not the only thing standing between this and a hung process).
23
+ */
24
+ class SocketTransport {
25
+ constructor(origin, options) {
26
+ this.origin = origin;
27
+ this.options = options;
28
+ this.socket = null;
29
+ this.connecting = null;
30
+ this.idleTimer = null;
31
+ }
32
+ /** Sends one batch, connecting first if needed. Resolves with the server's ack or throws. */
33
+ async send(events) {
34
+ this.clearIdleTimer();
35
+ try {
36
+ await this.ensureConnected();
37
+ return await this.emit(events);
38
+ }
39
+ finally {
40
+ this.scheduleIdleDisconnect();
41
+ }
42
+ }
43
+ destroy() {
44
+ this.clearIdleTimer();
45
+ this.socket?.removeAllListeners();
46
+ this.socket?.disconnect();
47
+ this.socket = null;
48
+ }
49
+ ensureConnected() {
50
+ if (this.socket?.connected)
51
+ return Promise.resolve();
52
+ if (this.connecting)
53
+ return this.connecting;
54
+ if (!this.socket) {
55
+ this.socket = this.createSocket();
56
+ }
57
+ else if (!this.socket.active) {
58
+ // Cleanly disconnected earlier (our own idle timeout, or the server closed
59
+ // it): socket.io-client won't retry on its own, so ask it to.
60
+ this.socket.connect();
61
+ }
62
+ // Otherwise the existing socket is already reconnecting on its own; just wait below.
63
+ const socket = this.socket;
64
+ this.connecting = new Promise((resolve, reject) => {
65
+ const timer = setTimeout(() => {
66
+ cleanup();
67
+ reject(new TransportError(`connection not established within ${this.options.connectTimeoutMs}ms`, "ECONNECT_TIMEOUT"));
68
+ }, this.options.connectTimeoutMs);
69
+ const onConnect = () => {
70
+ cleanup();
71
+ resolve();
72
+ };
73
+ const onError = (err) => {
74
+ cleanup();
75
+ reject(classifyConnectError(err));
76
+ };
77
+ const cleanup = () => {
78
+ clearTimeout(timer);
79
+ socket.off("connect", onConnect);
80
+ socket.off("connect_error", onError);
81
+ };
82
+ socket.once("connect", onConnect);
83
+ socket.once("connect_error", onError);
84
+ }).finally(() => {
85
+ this.connecting = null;
86
+ });
87
+ return this.connecting;
88
+ }
89
+ createSocket() {
90
+ const socket = (0, socket_io_client_1.io)(`${this.origin.origin}${INGEST_NAMESPACE}`, {
91
+ auth: { apiKey: this.options.apiKey },
92
+ transports: ["websocket"],
93
+ reconnection: true,
94
+ reconnectionAttempts: Infinity,
95
+ reconnectionDelay: this.options.reconnectionDelayMs,
96
+ reconnectionDelayMax: this.options.reconnectionDelayMaxMs,
97
+ randomizationFactor: 0.5,
98
+ timeout: this.options.connectTimeoutMs,
99
+ // Never the reason the process stays alive — same property the old HTTP
100
+ // transport's unref'd keep-alive sockets had.
101
+ autoUnref: true,
102
+ forceNew: true,
103
+ ca: this.options.ca,
104
+ extraHeaders: { "user-agent": this.options.userAgent },
105
+ });
106
+ return socket;
107
+ }
108
+ emit(events) {
109
+ const socket = this.socket;
110
+ if (!socket) {
111
+ return Promise.reject(new TransportError("not connected", "ENOTCONNECTED"));
112
+ }
113
+ return new Promise((resolve, reject) => {
114
+ socket.timeout(this.options.timeoutMs).emit(INGEST_EVENT, { events }, (err, ack) => {
115
+ if (err) {
116
+ reject(new TransportError(`no response within ${this.options.timeoutMs}ms`, "ETIMEDOUT"));
117
+ }
118
+ else {
119
+ resolve(ack);
120
+ }
121
+ });
122
+ });
123
+ }
124
+ scheduleIdleDisconnect() {
125
+ this.idleTimer = setTimeout(() => {
126
+ this.idleTimer = null;
127
+ this.socket?.disconnect();
128
+ }, this.options.idleDisconnectMs);
129
+ this.idleTimer.unref?.();
130
+ }
131
+ clearIdleTimer() {
132
+ if (this.idleTimer) {
133
+ clearTimeout(this.idleTimer);
134
+ this.idleTimer = null;
135
+ }
136
+ }
137
+ }
138
+ exports.SocketTransport = SocketTransport;
139
+ /**
140
+ * `connect_error` from a plain `io()` connect-timeout has message "timeout" and
141
+ * no further detail. Anything else wraps the real Node error (ECONNREFUSED,
142
+ * ENOTFOUND, a TLS failure, ...) inside engine.io-client's TransportError, whose
143
+ * `.description` — for the websocket transport — is a `ws` ErrorEvent exposing
144
+ * the underlying error via its public `.error` getter (mirrors the DOM
145
+ * ErrorEvent.error field; see ws/lib/event-target.js). Verified empirically
146
+ * against the installed socket.io-client/ws versions, not assumed.
147
+ */
148
+ function classifyConnectError(err) {
149
+ const anyErr = err;
150
+ if (anyErr?.message === "timeout") {
151
+ return new TransportError("connection timed out", "ECONNECT_TIMEOUT");
152
+ }
153
+ const inner = anyErr?.description?.error;
154
+ const code = inner?.code ?? "";
155
+ const message = inner?.message ?? anyErr?.message ?? "connection failed";
156
+ return new TransportError(message, code);
157
+ }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "midline-agent",
3
- "version": "0.4.0",
4
- "description": "Midline — request, error and security monitoring for Node, and error, network and Web Vitals monitoring for browsers",
3
+ "version": "0.5.0",
4
+ "description": "Midline — request, error and security monitoring for Node, and error, network, Web Vitals and pageview/visitor monitoring for browsers",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "exports": {
@@ -55,9 +55,13 @@
55
55
  "@types/express": "^4.17.19",
56
56
  "@types/node": "^24.10.1",
57
57
  "express": "^4.18.2",
58
+ "socket.io": "^4.8.3",
58
59
  "typescript": "^5.1.3"
59
60
  },
60
61
  "publishConfig": {
61
62
  "access": "public"
63
+ },
64
+ "dependencies": {
65
+ "socket.io-client": "^4.8.3"
62
66
  }
63
67
  }
package/src/agent.ts CHANGED
@@ -2,7 +2,7 @@ import { ConfigError, ResolvedCapture, ResolvedConfig, envFlag, resolveConfig }
2
2
  import { ConsoleCapture, ConsoleLevel, ConsoleLine, MAX_LINE_CHARS, withoutConsoleCapture } from "./console";
3
3
  import type { RequestContext } from "./context";
4
4
  import { Redactor } from "./redact";
5
- import { Transport } from "./transport";
5
+ import { IngestAck, SocketTransport } from "./socket-transport";
6
6
  import { CapturedMessage, EventCategory, EventSeverity, MidlineConfig, MidlineEvent } from "./types";
7
7
 
8
8
  export const SDK_VERSION: string = (() => {
@@ -117,7 +117,7 @@ export class MidlineAgent {
117
117
 
118
118
  private readonly config: ResolvedConfig | null = null;
119
119
  private readonly redactor: Redactor;
120
- private readonly transport: Transport | null = null;
120
+ private readonly transport: SocketTransport | null = null;
121
121
  private readonly onErrorHook?: (message: string) => void;
122
122
  private readonly debugLogs: boolean;
123
123
 
@@ -165,16 +165,20 @@ export class MidlineAgent {
165
165
  }
166
166
 
167
167
  this.config = resolved;
168
- this.transport = new Transport(resolved.ingestUrl, {
168
+ this.transport = new SocketTransport(resolved.socketOrigin, {
169
+ apiKey: resolved.apiKey,
169
170
  ca: resolved.ca,
170
171
  connectTimeoutMs: resolved.connectTimeoutMs,
171
172
  timeoutMs: resolved.timeoutMs,
173
+ reconnectionDelayMs: resolved.flushIntervalMs,
174
+ reconnectionDelayMaxMs: resolved.maxRetryDelayMs,
175
+ idleDisconnectMs: Math.max(5 * resolved.flushIntervalMs, 30_000),
172
176
  userAgent: `midline-agent/${SDK_VERSION} node/${process.version}`,
173
177
  });
174
178
 
175
179
  this.timer = setInterval(() => {
176
180
  this.consoleCapture?.flushPending();
177
- void this.drain(false);
181
+ void this.drain();
178
182
  }, resolved.flushIntervalMs);
179
183
  // Telemetry must never be the reason a process refuses to exit.
180
184
  this.timer.unref?.();
@@ -288,7 +292,15 @@ export class MidlineAgent {
288
292
  return true;
289
293
  }
290
294
 
291
- /** Sends whatever is buffered now, ignoring backoff, and keeps the process alive until done. */
295
+ /**
296
+ * Sends whatever is buffered now, ignoring backoff.
297
+ *
298
+ * The transport's connection is always unref'd (telemetry alone must never
299
+ * keep an otherwise-idle process alive), so this relies on something else in
300
+ * the process keeping the event loop open until the await resolves — true for
301
+ * the overwhelmingly common case (a running server), not guaranteed for a
302
+ * bare script whose last statement is `await agent.flush()`.
303
+ */
292
304
  async flush(): Promise<void> {
293
305
  if (!this.active) return;
294
306
  this.consoleCapture?.flushPending(true);
@@ -296,7 +308,7 @@ export class MidlineAgent {
296
308
  if (this.drainPromise) {
297
309
  await this.drainPromise;
298
310
  }
299
- await this.drain(true);
311
+ await this.drain();
300
312
  }
301
313
 
302
314
  /** Flushes with a deadline, then closes. For graceful shutdown. */
@@ -493,23 +505,23 @@ export class MidlineAgent {
493
505
  return batch;
494
506
  }
495
507
 
496
- private drain(keepProcessAlive: boolean): Promise<void> {
508
+ private drain(): Promise<void> {
497
509
  if (this.drainPromise) return this.drainPromise;
498
510
  if (!this.active || !this.queue.length || Date.now() < this.retryAfter) {
499
511
  return Promise.resolve();
500
512
  }
501
- this.drainPromise = this.runDrain(keepProcessAlive).finally(() => {
513
+ this.drainPromise = this.runDrain().finally(() => {
502
514
  this.drainPromise = null;
503
515
  });
504
516
  return this.drainPromise;
505
517
  }
506
518
 
507
- private async runDrain(keepProcessAlive: boolean): Promise<void> {
519
+ private async runDrain(): Promise<void> {
508
520
  while (this.queue.length && this.active) {
509
521
  // Taken off the queue while in flight, so overflow trimming can't remove
510
522
  // events that are mid-send and then be confused about what was accepted.
511
523
  const batch = this.takeBatch();
512
- const outcome = await this.send(batch, keepProcessAlive);
524
+ const outcome = await this.send(batch);
513
525
 
514
526
  if (outcome.kind === "ok") {
515
527
  this.onSuccess();
@@ -530,7 +542,7 @@ export class MidlineAgent {
530
542
  this.requeue(batch);
531
543
  } else {
532
544
  this.dropped += 1;
533
- this.report("http-413", "midline: the Midline server rejected an event as too large (HTTP 413); dropped it.");
545
+ this.report("too-large", "midline: the Midline server rejected an event as too large; dropped it.");
534
546
  }
535
547
  continue;
536
548
  }
@@ -543,7 +555,7 @@ export class MidlineAgent {
543
555
  }
544
556
  // One malformed event fails validation for the whole batch. Send them one
545
557
  // at a time so it only costs that event.
546
- const isolated = await this.sendIndividually(batch, keepProcessAlive);
558
+ const isolated = await this.sendIndividually(batch);
547
559
  if (!isolated) return;
548
560
  continue;
549
561
  }
@@ -555,14 +567,14 @@ export class MidlineAgent {
555
567
  }
556
568
 
557
569
  /** Returns false if delivery should stop for this drain. */
558
- private async sendIndividually(batch: QueuedEvent[], keepProcessAlive: boolean): Promise<boolean> {
570
+ private async sendIndividually(batch: QueuedEvent[]): Promise<boolean> {
559
571
  for (let index = 0; index < batch.length; index++) {
560
572
  if (this.consoleUnsupported && batch[index].wire.eventType === "console") continue;
561
- const outcome = await this.send([batch[index]], keepProcessAlive);
573
+ const outcome = await this.send([batch[index]]);
562
574
  if (outcome.kind === "ok") {
563
575
  this.onSuccess();
564
576
  } else if (outcome.kind === "rejected" || outcome.kind === "tooLarge") {
565
- const detail = outcome.kind === "rejected" ? outcome.detail : "HTTP 413";
577
+ const detail = outcome.kind === "rejected" ? outcome.detail : "too_large";
566
578
  if (outcome.kind === "rejected" && this.refusedConsole(batch[index], detail)) continue;
567
579
  this.dropped += 1;
568
580
  this.report(`rejected:${detail}`, `midline: the Midline server rejected an event (${detail}); dropped it.`);
@@ -578,60 +590,44 @@ export class MidlineAgent {
578
590
  return true;
579
591
  }
580
592
 
581
- private async send(batch: QueuedEvent[], keepProcessAlive: boolean): Promise<Outcome> {
582
- const config = this.config!;
583
- const body = JSON.stringify({ events: batch.map((item) => item.wire) });
584
-
585
- let result;
593
+ private async send(batch: QueuedEvent[]): Promise<Outcome> {
594
+ let ack: IngestAck;
586
595
  try {
587
- result = await this.transport!.post(config.batchUrl, body, { "x-api-key": config.apiKey }, keepProcessAlive);
596
+ ack = await this.transport!.send(batch.map((item) => item.wire));
588
597
  } catch (err) {
589
598
  this.report(`transport:${errorCode(err)}`, this.describe(err, batch.length));
590
599
  return { kind: "retry" };
591
600
  }
592
601
 
593
- const { status } = result;
594
- if (status >= 200 && status < 300) {
595
- // Servers that predate 401-on-bad-key answer 201 and count the rejects.
596
- const summary = parseJson(result.body);
597
- const failed = typeof summary?.failed === "number" ? summary.failed : 0;
598
- if (failed >= batch.length && batch.length > 0) {
602
+ if (ack.ok) {
603
+ // Servers that predate 401-on-bad-key ack ok and count the rejects.
604
+ if (ack.rejected >= batch.length && batch.length > 0) {
599
605
  this.log("error", "midline: the Midline server did not accept this API key. Monitoring is now off — retrying wouldn't help. Check apiKey / MIDLINE_API_KEY.");
600
606
  return { kind: "stop" };
601
607
  }
602
- if (failed > 0) {
603
- this.dropped += failed;
604
- this.report("partial", `midline: the Midline server rejected ${failed} of ${batch.length} events.`);
608
+ if (ack.rejected > 0) {
609
+ this.dropped += ack.rejected;
610
+ this.report("partial", `midline: the Midline server rejected ${ack.rejected} of ${batch.length} events.`);
605
611
  }
606
612
  return { kind: "ok" };
607
613
  }
608
- if (status === 401 || status === 403) {
614
+
615
+ if (ack.code === "unauthorized" || ack.code === "forbidden") {
609
616
  this.log(
610
617
  "error",
611
- `midline: the Midline server rejected the API key (HTTP ${status}). ` +
618
+ `midline: the Midline server rejected the API key (${ack.code}). ` +
612
619
  "Monitoring is now off — retrying wouldn't help. Check apiKey / MIDLINE_API_KEY.",
613
620
  );
614
621
  return { kind: "stop" };
615
622
  }
616
- if (status === 413) {
623
+ if (ack.code === "too_large") {
617
624
  return { kind: "tooLarge" };
618
625
  }
619
- if (status === 408 || status === 429 || status >= 500) {
620
- const retryAfterMs = parseRetryAfter(result.headers["retry-after"]);
621
- this.report(`http-${status}`, `midline: the Midline server returned HTTP ${status}; events are buffered and will be retried.`);
622
- return { kind: "retry", retryAfterMs };
623
- }
624
- if (status >= 300 && status < 400) {
625
- // Never followed: that would hand the API key to wherever the redirect points.
626
- const location = String(result.headers.location ?? "").slice(0, 200);
627
- this.report(
628
- `http-${status}`,
629
- `midline: the Midline endpoint redirected (HTTP ${status}${location ? ` to ${location}` : ""}). ` +
630
- "Redirects are not followed; set MIDLINE_ENDPOINT to the final URL.",
631
- );
632
- return { kind: "retry" };
626
+ if (ack.code === "rate_limited") {
627
+ this.report("rate_limited", `midline: the Midline server is rate-limiting this project; events are buffered and will be retried.`);
628
+ return { kind: "retry", retryAfterMs: ack.retryAfterMs };
633
629
  }
634
- return { kind: "rejected", detail: `HTTP ${status}${serverMessage(result.body)}` };
630
+ return { kind: "rejected", detail: `${ack.code}: ${ack.message}` };
635
631
  }
636
632
 
637
633
  /**
@@ -666,7 +662,7 @@ export class MidlineAgent {
666
662
 
667
663
  private describe(err: unknown, inFlight: number): string {
668
664
  const config = this.config!;
669
- const origin = config.ingestUrl.origin;
665
+ const origin = config.socketOrigin.origin;
670
666
  const code = errorCode(err);
671
667
  const buffered = ` ${this.queue.length + inFlight} event(s) buffered; your application is unaffected.`;
672
668
 
@@ -687,7 +683,7 @@ export class MidlineAgent {
687
683
  return `midline: could not connect to ${origin} within ${config.connectTimeoutMs}ms; retrying with backoff.${buffered}`;
688
684
  }
689
685
  if (code === "ENOTFOUND" || code === "EAI_AGAIN") {
690
- return `midline: cannot resolve ${config.ingestUrl.hostname} (${code}) — check MIDLINE_ENDPOINT and DNS; retrying with backoff.${buffered}`;
686
+ return `midline: cannot resolve ${config.socketOrigin.hostname} (${code}) — check MIDLINE_ENDPOINT and DNS; retrying with backoff.${buffered}`;
691
687
  }
692
688
  if (code === "ECONNREFUSED") {
693
689
  return `midline: ${origin} refused the connection; retrying with backoff.${buffered}`;
@@ -828,26 +824,3 @@ function errorCode(err: unknown): string {
828
824
  const e = err as any;
829
825
  return String(e?.code ?? e?.cause?.code ?? e?.errno ?? e?.name ?? "");
830
826
  }
831
-
832
- function parseJson(text: string): any {
833
- try {
834
- return JSON.parse(text);
835
- } catch {
836
- return undefined;
837
- }
838
- }
839
-
840
- function serverMessage(body: string): string {
841
- const message = parseJson(body)?.message;
842
- const text = Array.isArray(message) ? message.join("; ") : typeof message === "string" ? message : "";
843
- return text ? `: ${text.slice(0, 300)}` : "";
844
- }
845
-
846
- function parseRetryAfter(header: string | string[] | undefined): number | undefined {
847
- const value = Array.isArray(header) ? header[0] : header;
848
- if (!value) return undefined;
849
- const seconds = Number(value);
850
- if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
851
- const date = Date.parse(value);
852
- return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined;
853
- }
@@ -44,7 +44,7 @@ const CONSOLE_SEVERITY: Record<ConsoleLevel, EventSeverity> = {
44
44
  /** Top-level fields the ingest API accepts from this SDK; it rejects the whole batch on anything else. */
45
45
  const WIRE_FIELDS = new Set<string>([
46
46
  "eventType", "route", "method", "statusCode", "responseTime", "severity", "category", "timestamp",
47
- "service", "environment", "release", "userAgent", "traceId", "spanId", "payload", "metadata",
47
+ "service", "environment", "release", "userAgent", "traceId", "spanId", "sessionId", "payload", "metadata",
48
48
  ]);
49
49
 
50
50
  interface Resolved {
@@ -58,6 +58,7 @@ interface Resolved {
58
58
  captureRequests: RequestCapture;
59
59
  consoleLevels: ConsoleLevel[];
60
60
  captureWebVitals: boolean;
61
+ capturePageviews: boolean;
61
62
  tracePropagationTargets?: Array<string | RegExp>;
62
63
  ignoreErrors: Array<string | RegExp>;
63
64
  ignoreUrls: Array<string | RegExp>;
@@ -124,6 +125,7 @@ function resolve(config: MidlineBrowserConfig): Resolved {
124
125
  captureRequests: config.captureRequests === undefined ? "failed" : config.captureRequests,
125
126
  consoleLevels: levels.filter((level) => level in CONSOLE_SEVERITY),
126
127
  captureWebVitals: config.captureWebVitals !== false,
128
+ capturePageviews: config.capturePageviews !== false,
127
129
  tracePropagationTargets: config.tracePropagationTargets,
128
130
  ignoreErrors: config.ignoreErrors ?? [],
129
131
  ignoreUrls: config.ignoreUrls ?? [],
@@ -222,6 +224,9 @@ export class BrowserClient {
222
224
  if (this.config.captureWebVitals) {
223
225
  this.teardowns.push(observeVitals(this.win, (report) => this.handleVitals(report)));
224
226
  }
227
+ if (this.config.capturePageviews) {
228
+ this.handlePageview(this.currentRoute(), this.win.document?.referrer || undefined);
229
+ }
225
230
 
226
231
  // Registered after the vitals listener so its event is queued before this sends.
227
232
  const onHide = () => {
@@ -399,6 +404,15 @@ export class BrowserClient {
399
404
  // A new view is a new trace, so its API calls don't blur into the last one's.
400
405
  this.traceId = randomHex(16);
401
406
  this.breadcrumb("navigation", `${pathOf(from)} -> ${pathOf(to)}`);
407
+ // history.pushState/replaceState have already run by the time this fires, so
408
+ // win.location — and therefore currentRoute() — reflects the new view.
409
+ if (this.config.capturePageviews) this.handlePageview(this.currentRoute());
410
+ }
411
+
412
+ private handlePageview(route: string, referrer?: string): void {
413
+ const event = this.base("pageview", route, "low", "application");
414
+ if (referrer) event.payload = { referrer: this.redactor.string(referrer, 2048) };
415
+ this.emit(event);
402
416
  }
403
417
 
404
418
  private handleVitals(report: VitalsReport): void {
@@ -438,7 +452,6 @@ export class BrowserClient {
438
452
  sdk: "midline-agent/browser",
439
453
  version: BROWSER_SDK_VERSION,
440
454
  runtime: "browser",
441
- sessionId: this.sessionId,
442
455
  page: { url: this.pageUrl(), path: this.currentRoute() },
443
456
  };
444
457
  if (this.user && (this.user.id || this.user.username)) metadata.user = { ...this.user };
@@ -456,6 +469,7 @@ export class BrowserClient {
456
469
  release: this.config.release,
457
470
  userAgent: clamp(nav?.userAgent, 512),
458
471
  traceId: this.traceId,
472
+ sessionId: this.sessionId,
459
473
  metadata,
460
474
  };
461
475
  }
@@ -42,6 +42,12 @@ export interface MidlineBrowserConfig {
42
42
  captureConsole?: boolean | ConsoleLevel[];
43
43
  /** LCP, INP, CLS, FCP and TTFB, reported once when the page is first hidden. Default true. */
44
44
  captureWebVitals?: boolean;
45
+ /**
46
+ * A `pageview` event on load and on every SPA route change (history push/replace/popstate),
47
+ * carrying the same per-tab `sessionId` as every other event. This is what a visitor-count or
48
+ * funnel view in the Midline dashboard is built from. Default true.
49
+ */
50
+ capturePageviews?: boolean;
45
51
 
46
52
  /**
47
53
  * Requests that get a W3C `traceparent` header, so the backend's midline-agent
@@ -81,7 +87,7 @@ export interface MidlineBrowserConfig {
81
87
 
82
88
  /** An event as it is sent to the ingest API. */
83
89
  export interface BrowserEvent {
84
- eventType: "error" | "request" | "console" | "performance" | "custom";
90
+ eventType: "error" | "request" | "console" | "performance" | "pageview" | "custom";
85
91
  route: string;
86
92
  method?: string;
87
93
  statusCode?: number;
@@ -95,6 +101,8 @@ export interface BrowserEvent {
95
101
  userAgent?: string;
96
102
  traceId?: string;
97
103
  spanId?: string;
104
+ /** Stable for the tab's lifetime (sessionStorage-backed). Same value on every event this SDK sends. */
105
+ sessionId?: string;
98
106
  payload?: Record<string, unknown>;
99
107
  metadata: Record<string, unknown>;
100
108
  }
@@ -1,2 +1,2 @@
1
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";
2
+ export const BROWSER_SDK_VERSION = "0.5.0";
package/src/config.ts CHANGED
@@ -4,7 +4,6 @@ import * as tls from "tls";
4
4
  import { CaInput, CaptureOptions, MidlineConfig } from "./types";
5
5
 
6
6
  export const DEFAULT_ENDPOINT = "https://api.usemidline.com";
7
- export const INGEST_PATH = "/api/api-monitor/events";
8
7
 
9
8
  /** A setting that cannot work as given. Thrown at construction, never while serving traffic. */
10
9
  export class ConfigError extends Error {
@@ -25,8 +24,8 @@ export interface ResolvedCapture {
25
24
  export interface ResolvedConfig {
26
25
  apiKey: string;
27
26
  serviceName?: string;
28
- ingestUrl: URL;
29
- batchUrl: URL;
27
+ /** The Midline server's origin; the ingest gateway lives at `${socketOrigin}/ingest`. */
28
+ socketOrigin: URL;
30
29
  /** Full trust store for the Midline endpoint, or undefined for Node's default. */
31
30
  ca?: Array<string | Buffer>;
32
31
  hasCustomCa: boolean;
@@ -83,12 +82,14 @@ export function isLoopback(hostname: string): boolean {
83
82
  }
84
83
 
85
84
  /**
86
- * Turns whatever the user configured into the ingest URL.
87
- *
85
+ * Turns whatever the user configured into the Midline server's origin — the
86
+ * agent connects to `${origin}/ingest` over socket.io, so only the origin
87
+ * matters now. Tolerates an old-style full ingest URL some existing
88
+ * `MIDLINE_ENDPOINT` values still carry (from before the socket.io transport):
88
89
  * `https://api.usemidline.com`, `https://api.usemidline.com/api/api-monitor/events`
89
- * and `https://gateway.internal/midline` all work; the last is treated as a prefix.
90
+ * and `https://gateway.internal/midline` all resolve to the same origin.
90
91
  */
91
- export function resolveIngestUrl(endpoint: string): URL {
92
+ export function resolveEndpointOrigin(endpoint: string): URL {
92
93
  let url: URL;
93
94
  try {
94
95
  url = new URL(endpoint);
@@ -109,18 +110,7 @@ export function resolveIngestUrl(endpoint: string): URL {
109
110
  throw new ConfigError("endpoint must not contain credentials; pass the key as apiKey");
110
111
  }
111
112
 
112
- url.search = "";
113
- url.hash = "";
114
-
115
- const path = url.pathname.replace(/\/+$/, "");
116
- if (path.endsWith(`${INGEST_PATH}/batch`)) {
117
- url.pathname = path.slice(0, -"/batch".length);
118
- } else if (path.endsWith(INGEST_PATH)) {
119
- url.pathname = path;
120
- } else {
121
- url.pathname = `${path}${INGEST_PATH}`;
122
- }
123
- return url;
113
+ return new URL(url.origin);
124
114
  }
125
115
 
126
116
  /**
@@ -198,18 +188,17 @@ export function resolveCapture(capture: CaptureOptions | undefined): ResolvedCap
198
188
 
199
189
  export function resolveConfig(config: MidlineConfig): ResolvedConfig {
200
190
  const apiKey = (config.apiKey ?? env("MIDLINE_API_KEY") ?? "").trim();
201
- const ingestUrl = resolveIngestUrl(config.endpoint || env("MIDLINE_ENDPOINT") || DEFAULT_ENDPOINT);
191
+ const socketOrigin = resolveEndpointOrigin(config.endpoint || env("MIDLINE_ENDPOINT") || DEFAULT_ENDPOINT);
202
192
  const extraCa = loadCa(config.ca ?? env("MIDLINE_CUSTOM_CA"), "ca / MIDLINE_CUSTOM_CA");
203
193
 
204
- if (extraCa && ingestUrl.protocol !== "https:") {
194
+ if (extraCa && socketOrigin.protocol !== "https:") {
205
195
  throw new ConfigError("ca / MIDLINE_CUSTOM_CA is set but the endpoint is not https://");
206
196
  }
207
197
 
208
198
  return {
209
199
  apiKey,
210
200
  serviceName: config.serviceName ?? env("MIDLINE_SERVICE_NAME"),
211
- ingestUrl,
212
- batchUrl: new URL(`${ingestUrl.pathname}/batch`, ingestUrl),
201
+ socketOrigin,
213
202
  ca: trustStore(extraCa),
214
203
  hasCustomCa: Boolean(extraCa),
215
204
  environment: config.environment ?? env("MIDLINE_ENVIRONMENT"),