midline-agent 0.3.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +90 -5
- package/browser/package.json +8 -0
- package/dist/agent.d.ts +9 -1
- package/dist/agent.js +44 -68
- package/dist/browser/client.d.ts +59 -0
- package/dist/browser/client.js +608 -0
- package/dist/browser/index.d.ts +34 -0
- package/dist/browser/index.js +65 -0
- package/dist/browser/instrument.d.ts +39 -0
- package/dist/browser/instrument.js +217 -0
- package/dist/browser/transport.d.ts +43 -0
- package/dist/browser/transport.js +168 -0
- package/dist/browser/types.d.ts +94 -0
- package/dist/browser/types.js +2 -0
- package/dist/browser/version.d.ts +2 -0
- package/dist/browser/version.js +5 -0
- package/dist/browser/vitals.d.ts +16 -0
- package/dist/browser/vitals.js +135 -0
- package/dist/cli.js +0 -0
- package/dist/config.d.ts +8 -7
- package/dist/config.js +12 -24
- package/dist/esm/browser/client.js +601 -0
- package/dist/esm/browser/index.js +52 -0
- package/dist/esm/browser/instrument.js +210 -0
- package/dist/esm/browser/transport.js +164 -0
- package/dist/esm/browser/types.js +1 -0
- package/dist/esm/browser/version.js +2 -0
- package/dist/esm/browser/vitals.js +132 -0
- package/dist/esm/package.json +1 -0
- package/dist/esm/redact.js +224 -0
- package/dist/esm/types.js +1 -0
- package/dist/redact.d.ts +3 -0
- package/dist/redact.js +12 -6
- package/dist/socket-transport.d.ts +58 -0
- package/dist/socket-transport.js +157 -0
- package/package.json +31 -4
- package/scripts/mark-esm.js +6 -0
- package/src/agent.ts +46 -73
- package/src/browser/client.ts +686 -0
- package/src/browser/index.ts +74 -0
- package/src/browser/instrument.ts +275 -0
- package/src/browser/transport.ts +184 -0
- package/src/browser/types.ts +105 -0
- package/src/browser/version.ts +2 -0
- package/src/browser/vitals.ts +149 -0
- package/src/config.ts +12 -23
- package/src/redact.ts +12 -6
- package/src/socket-transport.ts +188 -0
- package/test/agent.test.js +47 -51
- package/test/browser.test.js +328 -0
- 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/tsconfig.esm.json +14 -0
- package/src/transport.ts +0 -125
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared by the Node agent and the browser SDK, so nothing here may touch a
|
|
3
|
+
* Node-only global (Buffer, process) — TextEncoder and URLSearchParams exist in both.
|
|
4
|
+
*
|
|
5
|
+
* Redaction happens in the host process, before an event is queued. Whatever is
|
|
6
|
+
* removed here never reaches a socket, a log line or the Midline server.
|
|
7
|
+
*
|
|
8
|
+
* Matching is on a normalised key — lower-cased with punctuation stripped — so
|
|
9
|
+
* `X-API-Key`, `api_key` and `apiKey` are all the same key.
|
|
10
|
+
*/
|
|
11
|
+
export const REDACTED = "[REDACTED]";
|
|
12
|
+
/** Substrings: any key containing one of these is sensitive. */
|
|
13
|
+
const SENSITIVE_KEY_PARTS = [
|
|
14
|
+
"password",
|
|
15
|
+
"passwd",
|
|
16
|
+
"passphrase",
|
|
17
|
+
"secret",
|
|
18
|
+
"token",
|
|
19
|
+
"apikey",
|
|
20
|
+
"accesskey",
|
|
21
|
+
"privatekey",
|
|
22
|
+
"authorization",
|
|
23
|
+
"cookie",
|
|
24
|
+
"session",
|
|
25
|
+
"credential",
|
|
26
|
+
"csrf",
|
|
27
|
+
"xsrf",
|
|
28
|
+
"signature",
|
|
29
|
+
"creditcard",
|
|
30
|
+
"cardnumber",
|
|
31
|
+
"cvv",
|
|
32
|
+
"cvc",
|
|
33
|
+
"ssn",
|
|
34
|
+
"socialsecurity",
|
|
35
|
+
];
|
|
36
|
+
/** Whole keys only — as substrings these would hit words like "author" or "spinner". */
|
|
37
|
+
const SENSITIVE_KEYS_EXACT = new Set(["auth", "pwd", "pin", "otp", "sid", "jwt", "bearer"]);
|
|
38
|
+
/** Always redacted by name, even if a user-supplied list somehow unmatched them. */
|
|
39
|
+
export const DEFAULT_SENSITIVE_HEADERS = [
|
|
40
|
+
"authorization",
|
|
41
|
+
"proxy-authorization",
|
|
42
|
+
"cookie",
|
|
43
|
+
"set-cookie",
|
|
44
|
+
"x-api-key",
|
|
45
|
+
"api-key",
|
|
46
|
+
"x-auth-token",
|
|
47
|
+
"x-access-token",
|
|
48
|
+
"x-refresh-token",
|
|
49
|
+
"x-csrf-token",
|
|
50
|
+
"x-xsrf-token",
|
|
51
|
+
"x-amz-security-token",
|
|
52
|
+
];
|
|
53
|
+
const VALUE_PATTERNS = [
|
|
54
|
+
[/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, REDACTED],
|
|
55
|
+
[/\b(Bearer|Basic|Digest|Token)\s+[A-Za-z0-9._~+\/=-]{8,}/gi, `$1 ${REDACTED}`],
|
|
56
|
+
[/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g, REDACTED],
|
|
57
|
+
[/\bak_[A-Fa-f0-9]{16,}\b/g, REDACTED],
|
|
58
|
+
[/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}\b/g, REDACTED],
|
|
59
|
+
[/\bAKIA[0-9A-Z]{16}\b/g, REDACTED],
|
|
60
|
+
[/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s\/@:]+:[^\s\/@]+@/gi, `$1${REDACTED}@`],
|
|
61
|
+
];
|
|
62
|
+
/** `key=value` / `key: value` in query strings, log lines and error messages. */
|
|
63
|
+
const KEY_VALUE_PAIR = /(^|[?&;,\s(\[{])([A-Za-z0-9_.%-]{1,64})(\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s&#,;)\]}]+)/g;
|
|
64
|
+
/** `"key": value` in JSON text, including JSON that was cut off mid-value. */
|
|
65
|
+
const JSON_PAIR = /"([^"\\]{1,100})"\s*:\s*("(?:[^"\\]|\\.)*"?|-?\d+(?:\.\d+)?|true|false|null)/g;
|
|
66
|
+
export function normalizeKey(key) {
|
|
67
|
+
return key.toLowerCase().replace(/[^a-z0-9]/g, "");
|
|
68
|
+
}
|
|
69
|
+
export class Redactor {
|
|
70
|
+
constructor(extraFields = [], extraHeaders = []) {
|
|
71
|
+
this.extraKeys = extraFields.map(normalizeKey).filter(Boolean);
|
|
72
|
+
this.headerNames = new Set([...DEFAULT_SENSITIVE_HEADERS, ...extraHeaders].map((name) => name.toLowerCase()));
|
|
73
|
+
}
|
|
74
|
+
isSensitiveKey(key) {
|
|
75
|
+
const normalized = normalizeKey(key);
|
|
76
|
+
if (!normalized)
|
|
77
|
+
return false;
|
|
78
|
+
if (SENSITIVE_KEYS_EXACT.has(normalized))
|
|
79
|
+
return true;
|
|
80
|
+
if (SENSITIVE_KEY_PARTS.some((part) => normalized.includes(part)))
|
|
81
|
+
return true;
|
|
82
|
+
return this.extraKeys.some((part) => normalized.includes(part));
|
|
83
|
+
}
|
|
84
|
+
/** Masks credentials embedded in free text: bearer tokens, JWTs, key formats, URL userinfo and query params. */
|
|
85
|
+
string(value, maxLength = 2048) {
|
|
86
|
+
let out = value.length > maxLength * 4 ? value.slice(0, maxLength * 4) : value;
|
|
87
|
+
for (const [pattern, replacement] of VALUE_PATTERNS) {
|
|
88
|
+
out = out.replace(pattern, replacement);
|
|
89
|
+
}
|
|
90
|
+
if (out.includes("=") || out.includes(":")) {
|
|
91
|
+
out = out.replace(KEY_VALUE_PAIR, (match, sep, key, delimiter) => {
|
|
92
|
+
let decoded = key;
|
|
93
|
+
try {
|
|
94
|
+
decoded = decodeURIComponent(key);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
// keep the raw key
|
|
98
|
+
}
|
|
99
|
+
return this.isSensitiveKey(decoded) ? `${sep}${key}${delimiter}${REDACTED}` : match;
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
if (out.includes('"')) {
|
|
103
|
+
out = out.replace(JSON_PAIR, (match, key) => this.isSensitiveKey(key) ? `"${key}":"${REDACTED}"` : match);
|
|
104
|
+
}
|
|
105
|
+
// The ellipsis counts toward the limit: servers validate these lengths exactly.
|
|
106
|
+
return out.length > maxLength ? `${out.slice(0, Math.max(0, maxLength - 1))}…` : out;
|
|
107
|
+
}
|
|
108
|
+
/** Deep copy with sensitive keys and values masked. Bounded in depth, breadth and string length. */
|
|
109
|
+
value(input, depth = 0, seen = new WeakSet()) {
|
|
110
|
+
if (input === null || input === undefined)
|
|
111
|
+
return input;
|
|
112
|
+
if (typeof input === "string")
|
|
113
|
+
return this.string(input);
|
|
114
|
+
if (typeof input === "number" || typeof input === "boolean")
|
|
115
|
+
return input;
|
|
116
|
+
if (typeof input === "bigint")
|
|
117
|
+
return input.toString();
|
|
118
|
+
if (typeof input === "function" || typeof input === "symbol")
|
|
119
|
+
return undefined;
|
|
120
|
+
if (input instanceof Date)
|
|
121
|
+
return Number.isNaN(input.getTime()) ? null : input.toISOString();
|
|
122
|
+
// Buffer is a Uint8Array, so this also covers it without naming a Node-only global.
|
|
123
|
+
if (ArrayBuffer.isView(input)) {
|
|
124
|
+
return `[Binary ${input.byteLength} bytes]`;
|
|
125
|
+
}
|
|
126
|
+
if (typeof input !== "object")
|
|
127
|
+
return undefined;
|
|
128
|
+
if (seen.has(input))
|
|
129
|
+
return "[Circular]";
|
|
130
|
+
if (depth >= 8)
|
|
131
|
+
return "[Truncated]";
|
|
132
|
+
seen.add(input);
|
|
133
|
+
if (Array.isArray(input)) {
|
|
134
|
+
const items = input.slice(0, 100).map((item) => this.value(item, depth + 1, seen));
|
|
135
|
+
if (input.length > 100)
|
|
136
|
+
items.push(`[${input.length - 100} more]`);
|
|
137
|
+
return items;
|
|
138
|
+
}
|
|
139
|
+
const out = {};
|
|
140
|
+
let count = 0;
|
|
141
|
+
for (const [key, nested] of Object.entries(input)) {
|
|
142
|
+
if (count++ >= 200) {
|
|
143
|
+
out["[truncated]"] = "too many keys";
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
out[key] = this.isSensitiveKey(key) ? REDACTED : this.value(nested, depth + 1, seen);
|
|
147
|
+
}
|
|
148
|
+
return out;
|
|
149
|
+
}
|
|
150
|
+
headers(headers) {
|
|
151
|
+
if (!headers)
|
|
152
|
+
return undefined;
|
|
153
|
+
const out = {};
|
|
154
|
+
for (const [rawName, rawValue] of Object.entries(headers)) {
|
|
155
|
+
if (rawValue === undefined)
|
|
156
|
+
continue;
|
|
157
|
+
const name = rawName.toLowerCase();
|
|
158
|
+
if (this.headerNames.has(name) || this.isSensitiveKey(name)) {
|
|
159
|
+
out[name] = REDACTED;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
const joined = Array.isArray(rawValue) ? rawValue.join(", ") : String(rawValue);
|
|
163
|
+
out[name] = this.string(joined, 1024);
|
|
164
|
+
}
|
|
165
|
+
return out;
|
|
166
|
+
}
|
|
167
|
+
query(search) {
|
|
168
|
+
if (!search)
|
|
169
|
+
return undefined;
|
|
170
|
+
const out = {};
|
|
171
|
+
const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
|
|
172
|
+
for (const key of new Set(params.keys())) {
|
|
173
|
+
const values = params.getAll(key).map((value) => this.isSensitiveKey(key) ? REDACTED : this.string(value, 512));
|
|
174
|
+
out[key] = values.length === 1 ? values[0] : values;
|
|
175
|
+
}
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Redacts a captured body. Structured content is parsed and redacted by key;
|
|
180
|
+
* text that cannot be parsed (usually because it was truncated) gets key/value
|
|
181
|
+
* pattern masking instead, so a cut-off JSON body still loses its passwords.
|
|
182
|
+
*/
|
|
183
|
+
body(raw, contentType, maxBytes) {
|
|
184
|
+
if (raw === undefined || raw === null || maxBytes <= 0)
|
|
185
|
+
return {};
|
|
186
|
+
const type = (contentType || "").toLowerCase();
|
|
187
|
+
if (typeof raw === "object" && !ArrayBuffer.isView(raw)) {
|
|
188
|
+
return this.fit(this.value(raw), maxBytes);
|
|
189
|
+
}
|
|
190
|
+
const text = ArrayBuffer.isView(raw)
|
|
191
|
+
? new TextDecoder().decode(raw)
|
|
192
|
+
: String(raw);
|
|
193
|
+
if (type.includes("json")) {
|
|
194
|
+
try {
|
|
195
|
+
return this.fit(this.value(JSON.parse(text)), maxBytes);
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
return this.cut(this.string(text, text.length), maxBytes);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
if (type.includes("application/x-www-form-urlencoded")) {
|
|
202
|
+
return this.fit(this.query(text) ?? {}, maxBytes);
|
|
203
|
+
}
|
|
204
|
+
if (!type || type.startsWith("text/") || type.includes("xml") || type.includes("graphql")) {
|
|
205
|
+
return this.cut(this.string(text, maxBytes * 2), maxBytes);
|
|
206
|
+
}
|
|
207
|
+
return { omitted: `content-type ${type.split(";")[0]}` };
|
|
208
|
+
}
|
|
209
|
+
fit(value, maxBytes) {
|
|
210
|
+
const serialized = JSON.stringify(value) ?? "";
|
|
211
|
+
if (new TextEncoder().encode(serialized).length <= maxBytes) {
|
|
212
|
+
return { body: value };
|
|
213
|
+
}
|
|
214
|
+
return this.cut(serialized, maxBytes);
|
|
215
|
+
}
|
|
216
|
+
cut(text, maxBytes) {
|
|
217
|
+
const bytes = new TextEncoder().encode(text);
|
|
218
|
+
if (bytes.length <= maxBytes) {
|
|
219
|
+
return { body: text };
|
|
220
|
+
}
|
|
221
|
+
// Slicing bytes can split a multi-byte character; the replacement char is harmless here.
|
|
222
|
+
return { body: new TextDecoder().decode(bytes.subarray(0, maxBytes)), truncated: true };
|
|
223
|
+
}
|
|
224
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
package/dist/redact.d.ts
CHANGED
|
@@ -1,4 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
+
* Shared by the Node agent and the browser SDK, so nothing here may touch a
|
|
3
|
+
* Node-only global (Buffer, process) — TextEncoder and URLSearchParams exist in both.
|
|
4
|
+
*
|
|
2
5
|
* Redaction happens in the host process, before an event is queued. Whatever is
|
|
3
6
|
* removed here never reaches a socket, a log line or the Midline server.
|
|
4
7
|
*
|
package/dist/redact.js
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
/**
|
|
3
|
+
* Shared by the Node agent and the browser SDK, so nothing here may touch a
|
|
4
|
+
* Node-only global (Buffer, process) — TextEncoder and URLSearchParams exist in both.
|
|
5
|
+
*
|
|
3
6
|
* Redaction happens in the host process, before an event is queued. Whatever is
|
|
4
7
|
* removed here never reaches a socket, a log line or the Midline server.
|
|
5
8
|
*
|
|
@@ -120,7 +123,8 @@ class Redactor {
|
|
|
120
123
|
return undefined;
|
|
121
124
|
if (input instanceof Date)
|
|
122
125
|
return Number.isNaN(input.getTime()) ? null : input.toISOString();
|
|
123
|
-
|
|
126
|
+
// Buffer is a Uint8Array, so this also covers it without naming a Node-only global.
|
|
127
|
+
if (ArrayBuffer.isView(input)) {
|
|
124
128
|
return `[Binary ${input.byteLength} bytes]`;
|
|
125
129
|
}
|
|
126
130
|
if (typeof input !== "object")
|
|
@@ -184,10 +188,12 @@ class Redactor {
|
|
|
184
188
|
if (raw === undefined || raw === null || maxBytes <= 0)
|
|
185
189
|
return {};
|
|
186
190
|
const type = (contentType || "").toLowerCase();
|
|
187
|
-
if (typeof raw === "object" && !
|
|
191
|
+
if (typeof raw === "object" && !ArrayBuffer.isView(raw)) {
|
|
188
192
|
return this.fit(this.value(raw), maxBytes);
|
|
189
193
|
}
|
|
190
|
-
const text =
|
|
194
|
+
const text = ArrayBuffer.isView(raw)
|
|
195
|
+
? new TextDecoder().decode(raw)
|
|
196
|
+
: String(raw);
|
|
191
197
|
if (type.includes("json")) {
|
|
192
198
|
try {
|
|
193
199
|
return this.fit(this.value(JSON.parse(text)), maxBytes);
|
|
@@ -206,18 +212,18 @@ class Redactor {
|
|
|
206
212
|
}
|
|
207
213
|
fit(value, maxBytes) {
|
|
208
214
|
const serialized = JSON.stringify(value) ?? "";
|
|
209
|
-
if (
|
|
215
|
+
if (new TextEncoder().encode(serialized).length <= maxBytes) {
|
|
210
216
|
return { body: value };
|
|
211
217
|
}
|
|
212
218
|
return this.cut(serialized, maxBytes);
|
|
213
219
|
}
|
|
214
220
|
cut(text, maxBytes) {
|
|
215
|
-
const bytes =
|
|
221
|
+
const bytes = new TextEncoder().encode(text);
|
|
216
222
|
if (bytes.length <= maxBytes) {
|
|
217
223
|
return { body: text };
|
|
218
224
|
}
|
|
219
225
|
// Slicing bytes can split a multi-byte character; the replacement char is harmless here.
|
|
220
|
-
return { body: bytes.subarray(0, maxBytes)
|
|
226
|
+
return { body: new TextDecoder().decode(bytes.subarray(0, maxBytes)), truncated: true };
|
|
221
227
|
}
|
|
222
228
|
}
|
|
223
229
|
exports.Redactor = Redactor;
|
|
@@ -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,9 +1,29 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "midline-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Midline — request
|
|
3
|
+
"version": "0.4.1",
|
|
4
|
+
"description": "Midline — request, error and security monitoring for Node, and error, network and Web Vitals monitoring for browsers",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": {
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
},
|
|
12
|
+
"./browser": {
|
|
13
|
+
"types": "./dist/browser/index.d.ts",
|
|
14
|
+
"import": "./dist/esm/browser/index.js",
|
|
15
|
+
"default": "./dist/browser/index.js"
|
|
16
|
+
},
|
|
17
|
+
"./package.json": "./package.json",
|
|
18
|
+
"./dist/*": "./dist/*"
|
|
19
|
+
},
|
|
20
|
+
"typesVersions": {
|
|
21
|
+
"*": {
|
|
22
|
+
"browser": [
|
|
23
|
+
"dist/browser/index.d.ts"
|
|
24
|
+
]
|
|
25
|
+
}
|
|
26
|
+
},
|
|
7
27
|
"bin": {
|
|
8
28
|
"midline-agent": "dist/cli.js"
|
|
9
29
|
},
|
|
@@ -11,7 +31,7 @@
|
|
|
11
31
|
"node": ">=18"
|
|
12
32
|
},
|
|
13
33
|
"scripts": {
|
|
14
|
-
"build": "tsc",
|
|
34
|
+
"build": "tsc && tsc -p tsconfig.esm.json && node scripts/mark-esm.js",
|
|
15
35
|
"prepare": "npm run build",
|
|
16
36
|
"clean": "rm -rf dist",
|
|
17
37
|
"test": "npm run build && node --test test/*.test.js"
|
|
@@ -24,7 +44,10 @@
|
|
|
24
44
|
"security",
|
|
25
45
|
"request",
|
|
26
46
|
"errors",
|
|
27
|
-
"proxy"
|
|
47
|
+
"proxy",
|
|
48
|
+
"browser",
|
|
49
|
+
"web-vitals",
|
|
50
|
+
"frontend"
|
|
28
51
|
],
|
|
29
52
|
"author": "Your Name",
|
|
30
53
|
"license": "MIT",
|
|
@@ -32,9 +55,13 @@
|
|
|
32
55
|
"@types/express": "^4.17.19",
|
|
33
56
|
"@types/node": "^24.10.1",
|
|
34
57
|
"express": "^4.18.2",
|
|
58
|
+
"socket.io": "^4.8.3",
|
|
35
59
|
"typescript": "^5.1.3"
|
|
36
60
|
},
|
|
37
61
|
"publishConfig": {
|
|
38
62
|
"access": "public"
|
|
63
|
+
},
|
|
64
|
+
"dependencies": {
|
|
65
|
+
"socket.io-client": "^4.8.3"
|
|
39
66
|
}
|
|
40
67
|
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
// dist/esm holds the browser build as ES modules. Marking the folder as a module
|
|
2
|
+
// scope lets bundlers and Node treat those .js files as ESM without renaming them.
|
|
3
|
+
const fs = require("fs");
|
|
4
|
+
const path = require("path");
|
|
5
|
+
|
|
6
|
+
fs.writeFileSync(path.join(__dirname, "..", "dist", "esm", "package.json"), '{ "type": "module" }\n');
|