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.
- package/CHANGELOG.md +25 -0
- package/README.md +10 -7
- package/dist/agent.d.ts +9 -1
- package/dist/agent.js +44 -68
- package/dist/browser/client.d.ts +1 -0
- package/dist/browser/client.js +16 -2
- package/dist/browser/types.d.ts +9 -1
- package/dist/browser/version.d.ts +1 -1
- package/dist/browser/version.js +1 -1
- package/dist/config.d.ts +8 -7
- package/dist/config.js +12 -24
- package/dist/esm/browser/client.js +16 -2
- package/dist/esm/browser/version.js +1 -1
- package/dist/socket-transport.d.ts +58 -0
- package/dist/socket-transport.js +157 -0
- package/package.json +6 -2
- package/src/agent.ts +46 -73
- package/src/browser/client.ts +16 -2
- package/src/browser/types.ts +9 -1
- package/src/browser/version.ts +1 -1
- package/src/config.ts +12 -23
- package/src/socket-transport.ts +188 -0
- package/test/agent.test.js +47 -51
- package/test/browser.test.js +48 -2
- package/test/console.test.js +11 -10
- package/test/helpers.js +54 -1
- package/test/middleware.test.js +5 -5
- package/test/proxy.test.js +4 -4
- package/src/transport.ts +0 -125
|
@@ -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
|
-
"description": "Midline — request, error and security monitoring for Node, and error, network
|
|
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 {
|
|
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:
|
|
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
|
|
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(
|
|
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
|
-
/**
|
|
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(
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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("
|
|
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
|
|
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[]
|
|
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]]
|
|
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 : "
|
|
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[]
|
|
582
|
-
|
|
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
|
-
|
|
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
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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 (
|
|
603
|
-
this.dropped +=
|
|
604
|
-
this.report("partial", `midline: the Midline server rejected ${
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
623
|
+
if (ack.code === "too_large") {
|
|
617
624
|
return { kind: "tooLarge" };
|
|
618
625
|
}
|
|
619
|
-
if (
|
|
620
|
-
|
|
621
|
-
|
|
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:
|
|
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.
|
|
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.
|
|
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
|
-
}
|
package/src/browser/client.ts
CHANGED
|
@@ -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
|
}
|
package/src/browser/types.ts
CHANGED
|
@@ -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
|
}
|
package/src/browser/version.ts
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
29
|
-
|
|
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
|
|
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
|
|
90
|
+
* and `https://gateway.internal/midline` all resolve to the same origin.
|
|
90
91
|
*/
|
|
91
|
-
export function
|
|
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.
|
|
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
|
|
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 &&
|
|
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
|
-
|
|
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"),
|