midline-agent 0.1.8 → 0.2.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/src/tap.ts ADDED
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Keeps the first `limit` bytes of a stream that is being written elsewhere, and
3
+ * counts the rest. Copies what it keeps so a large chunk's backing buffer isn't
4
+ * retained just because its first few bytes were interesting.
5
+ */
6
+ export class BodyTap {
7
+ private readonly chunks: Buffer[] = [];
8
+ private kept = 0;
9
+ bytes = 0;
10
+
11
+ constructor(private readonly limit: number) {}
12
+
13
+ push(chunk: unknown, encoding?: string): void {
14
+ try {
15
+ if (chunk === undefined || chunk === null || typeof chunk === "function") return;
16
+ let buffer: Buffer;
17
+ if (Buffer.isBuffer(chunk)) {
18
+ buffer = chunk;
19
+ } else if (typeof chunk === "string") {
20
+ buffer = Buffer.from(chunk, Buffer.isEncoding(encoding ?? "") ? (encoding as BufferEncoding) : "utf8");
21
+ } else if (chunk instanceof Uint8Array) {
22
+ buffer = Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
23
+ } else {
24
+ return;
25
+ }
26
+
27
+ this.bytes += buffer.length;
28
+ if (this.kept < this.limit) {
29
+ const part = buffer.subarray(0, this.limit - this.kept);
30
+ this.chunks.push(Buffer.from(part));
31
+ this.kept += part.length;
32
+ }
33
+ } catch {
34
+ // Observing a body must never break writing it.
35
+ }
36
+ }
37
+
38
+ get truncated(): boolean {
39
+ return this.bytes > this.kept;
40
+ }
41
+
42
+ get body(): Buffer {
43
+ return Buffer.concat(this.chunks);
44
+ }
45
+ }
46
+
47
+ export function headerValue(value: unknown): string | undefined {
48
+ if (Array.isArray(value)) return value.length ? String(value[0]) : undefined;
49
+ return value === undefined || value === null ? undefined : String(value);
50
+ }
51
+
52
+ /** Compressed bytes are unreadable and not worth redacting; the body is skipped instead. */
53
+ export function isCompressed(contentEncoding: string | undefined): boolean {
54
+ return Boolean(contentEncoding && contentEncoding.toLowerCase() !== "identity");
55
+ }
@@ -0,0 +1,125 @@
1
+ import * as http from "http";
2
+ import * as https from "https";
3
+
4
+ export interface PostResult {
5
+ status: number;
6
+ headers: http.IncomingHttpHeaders;
7
+ body: string;
8
+ }
9
+
10
+ export interface TransportOptions {
11
+ /** Full trust store for https, or undefined for Node's default. Verification is never disabled. */
12
+ ca?: Array<string | Buffer>;
13
+ connectTimeoutMs: number;
14
+ timeoutMs: number;
15
+ userAgent: string;
16
+ /** Response bodies are only read for diagnostics; anything past this is discarded. */
17
+ maxResponseBytes?: number;
18
+ }
19
+
20
+ /** A transport failure with a stable `code`, including the two timeouts Node doesn't name. */
21
+ export class TransportError extends Error {
22
+ constructor(message: string, readonly code: string) {
23
+ super(message);
24
+ this.name = "TransportError";
25
+ }
26
+ }
27
+
28
+ /**
29
+ * Minimal JSON POST over Node's own http/https.
30
+ *
31
+ * Node's modules rather than fetch because this needs things fetch doesn't expose
32
+ * portably: a per-endpoint CA that extends rather than replaces the trust store, a
33
+ * connect timeout separate from the request deadline, and sockets that don't keep
34
+ * the host process alive.
35
+ */
36
+ export class Transport {
37
+ private readonly agent: http.Agent;
38
+
39
+ constructor(origin: URL, private readonly options: TransportOptions) {
40
+ this.agent = origin.protocol === "https:"
41
+ ? new https.Agent({ keepAlive: true, maxSockets: 4, ca: options.ca })
42
+ : new http.Agent({ keepAlive: true, maxSockets: 4 });
43
+ }
44
+
45
+ post(url: URL, body: string, headers: Record<string, string>, keepProcessAlive: boolean): Promise<PostResult> {
46
+ const { connectTimeoutMs, timeoutMs } = this.options;
47
+ const maxResponseBytes = this.options.maxResponseBytes ?? 64 * 1024;
48
+ const isHttps = url.protocol === "https:";
49
+
50
+ return new Promise<PostResult>((resolve, reject) => {
51
+ let settled = false;
52
+ let connectTimer: NodeJS.Timeout | undefined;
53
+
54
+ const settle = (fn: () => void) => {
55
+ if (settled) return;
56
+ settled = true;
57
+ clearTimeout(deadline);
58
+ if (connectTimer) clearTimeout(connectTimer);
59
+ fn();
60
+ };
61
+
62
+ const request = (isHttps ? https : http).request(url, {
63
+ method: "POST",
64
+ agent: this.agent,
65
+ headers: {
66
+ ...headers,
67
+ "content-type": "application/json",
68
+ "content-length": String(Buffer.byteLength(body)),
69
+ "user-agent": this.options.userAgent,
70
+ },
71
+ });
72
+
73
+ const deadline = setTimeout(() => {
74
+ request.destroy(new TransportError(`no response within ${timeoutMs}ms`, "ETIMEDOUT"));
75
+ }, timeoutMs);
76
+
77
+ request.on("socket", (socket) => {
78
+ if (!keepProcessAlive) {
79
+ socket.unref();
80
+ } else {
81
+ socket.ref();
82
+ }
83
+ // A pooled keep-alive socket is already connected; only time fresh ones.
84
+ // The overall deadline still covers a handshake that stalls after connect.
85
+ if ((socket as any).connecting) {
86
+ connectTimer = setTimeout(() => {
87
+ request.destroy(new TransportError(`connection not established within ${connectTimeoutMs}ms`, "ECONNECT_TIMEOUT"));
88
+ }, connectTimeoutMs);
89
+ socket.once(isHttps ? "secureConnect" : "connect", () => clearTimeout(connectTimer));
90
+ }
91
+ });
92
+
93
+ request.on("response", (response) => {
94
+ const chunks: Buffer[] = [];
95
+ let received = 0;
96
+ response.on("data", (chunk: Buffer) => {
97
+ if (received < maxResponseBytes) {
98
+ chunks.push(chunk.subarray(0, maxResponseBytes - received));
99
+ }
100
+ received += chunk.length;
101
+ });
102
+ response.on("end", () =>
103
+ settle(() =>
104
+ resolve({
105
+ status: response.statusCode ?? 0,
106
+ headers: response.headers,
107
+ body: Buffer.concat(chunks).toString("utf8"),
108
+ }),
109
+ ),
110
+ );
111
+ response.on("error", (err) => settle(() => reject(err)));
112
+ response.on("aborted", () =>
113
+ settle(() => reject(new TransportError("response aborted by the server", "ECONNRESET"))),
114
+ );
115
+ });
116
+
117
+ request.on("error", (err) => settle(() => reject(err)));
118
+ request.end(body);
119
+ });
120
+ }
121
+
122
+ destroy(): void {
123
+ this.agent.destroy();
124
+ }
125
+ }
package/src/types.ts CHANGED
@@ -1,32 +1,145 @@
1
+ /** A PEM string, PEM bytes, a path to a PEM file, or several of those. */
2
+ export type CaInput = string | Buffer | Array<string | Buffer>;
3
+
4
+ export type EventType = "request" | "error" | "security" | "performance" | "custom";
5
+ export type EventSeverity = "low" | "medium" | "high" | "critical";
6
+ export type EventCategory = "application" | "infrastructure" | "security" | "performance" | "business";
7
+
8
+ /**
9
+ * What gets copied off a request/response in addition to method, path, status and
10
+ * timing. Everything here is off by default: headers, query strings and bodies are
11
+ * where credentials and personal data live, so capturing them is an explicit
12
+ * decision. Whatever is captured is redacted in this process before it is queued.
13
+ */
14
+ export interface CaptureOptions {
15
+ /** Request and response headers. Default false. */
16
+ headers?: boolean;
17
+ /** Query-string parameters. Default false. */
18
+ query?: boolean;
19
+ /** Request body — the parsed `req.body` in middleware mode, the raw stream in proxy mode. Default false. */
20
+ requestBody?: boolean;
21
+ /** Response body. Default false. */
22
+ responseBody?: boolean;
23
+ /** Per-body cap in bytes; anything longer is cut and marked `truncated`. Default 4096. */
24
+ maxBodyBytes?: number;
25
+ }
26
+
1
27
  export interface MidlineConfig {
2
- apiKey: string;
3
- serviceName: string;
4
- maskFields?: string[];
5
- endpoint?: string;
6
- environment?: string;
7
- host?: string;
8
- region?: string;
9
- /** Release/version tag shows up on every event so Midline can tell you which deploy introduced or reintroduced an issue. */
10
- release?: string;
11
- }
12
-
13
- export interface Breadcrumb {
14
- type: string;
15
- message: string;
16
- timestamp: string;
17
- }
18
-
19
- export interface MidlineEvent {
20
- type: "request" | "error";
21
- timestamp: string;
22
- path: string;
23
- method?: string;
24
- statusCode?: number;
25
- duration?: number;
26
- message?: string;
27
- stack?: string;
28
- ip?: string;
29
- userAgent?: string;
30
- /** Recent requests leading up to this event — only populated on errors. */
31
- breadcrumbs?: Breadcrumb[];
32
- }
28
+ /** Project API key from the Midline dashboard. Env fallback: `MIDLINE_API_KEY`. */
29
+ apiKey?: string;
30
+ /** Name of the service reporting. Env fallback: `MIDLINE_SERVICE_NAME`. */
31
+ serviceName?: string;
32
+
33
+ /**
34
+ * The Midline server — the control plane that receives events. Accepts a base URL
35
+ * (`https://api.usemidline.com`) or the full ingest URL. `https://` is required;
36
+ * plain `http://` is only accepted for loopback hosts, so the API key never
37
+ * crosses a network in cleartext. Env fallback: `MIDLINE_ENDPOINT`.
38
+ *
39
+ * This is never the API being monitored. See `createMidlineProxy({ target })`.
40
+ */
41
+ endpoint?: string;
42
+
43
+ /**
44
+ * Extra certificate authorities to trust for `endpoint` only — for a self-hosted
45
+ * Midline server behind a private CA. Added to Node's default trust store, never a
46
+ * replacement for it, and never a way to switch verification off. Env fallback:
47
+ * `MIDLINE_CUSTOM_CA` (PEM text or a path to a PEM file).
48
+ */
49
+ ca?: CaInput;
50
+
51
+ environment?: string;
52
+ host?: string;
53
+ region?: string;
54
+ /** Release/version tag — shows up on every event so Midline can tell you which deploy introduced or reintroduced an issue. */
55
+ release?: string;
56
+
57
+ /** Extra field names to redact, on top of the built-in list. Matched case- and punctuation-insensitively. */
58
+ redactFields?: string[];
59
+ /** @deprecated Use `redactFields`. Still honoured. */
60
+ maskFields?: string[];
61
+ /** Extra header names to redact, on top of the built-in list. */
62
+ redactHeaders?: string[];
63
+ /** What to capture beyond method/path/status/timing. Defaults to nothing. */
64
+ capture?: CaptureOptions;
65
+
66
+ /** Set to false to keep the agent inert. Env fallback: `MIDLINE_ENABLED`. */
67
+ enabled?: boolean;
68
+
69
+ /**
70
+ * Receives the agent's own diagnostics so they can go to your logger instead of
71
+ * the console. Set `debug` too if you want them in both places.
72
+ */
73
+ onError?: (message: string) => void;
74
+ /** Emit every diagnostic instead of one per distinct fault. Env fallback: `MIDLINE_DEBUG`. */
75
+ debug?: boolean;
76
+
77
+ /** How often the queue is flushed, in ms. Default 1500. */
78
+ flushIntervalMs?: number;
79
+ /** Deadline for a whole delivery request, in ms. Default 10000. */
80
+ timeoutMs?: number;
81
+ /** Deadline for the TCP + TLS handshake, in ms. Default 5000. */
82
+ connectTimeoutMs?: number;
83
+ /** Events per delivery request. Default 100, max 500. */
84
+ maxBatchSize?: number;
85
+ /** Events buffered while the Midline server is unreachable. Oldest are dropped past this. Default 1000. */
86
+ maxQueueSize?: number;
87
+ /** Serialised size cap for a single event; larger events lose their captured bodies first. Default and maximum 65536. */
88
+ maxEventBytes?: number;
89
+ /** Upper bound on retry backoff, in ms. Default 300000. */
90
+ maxRetryDelayMs?: number;
91
+ }
92
+
93
+ export interface Breadcrumb {
94
+ type: string;
95
+ message: string;
96
+ timestamp: string;
97
+ }
98
+
99
+ export interface CapturedMessage {
100
+ headers?: Record<string, string>;
101
+ query?: Record<string, string | string[]>;
102
+ body?: unknown;
103
+ /** Bytes seen on the wire (or serialised), before truncation. */
104
+ bodyBytes?: number;
105
+ truncated?: boolean;
106
+ /** Why a body was not captured, e.g. a binary content type. */
107
+ omitted?: string;
108
+ }
109
+
110
+ export interface MidlineEvent {
111
+ type: EventType;
112
+ /** ISO timestamp. Defaults to the moment the event is added. */
113
+ timestamp?: string;
114
+ path: string;
115
+ method?: string;
116
+ statusCode?: number;
117
+ duration?: number;
118
+ message?: string;
119
+ stack?: string;
120
+ ip?: string;
121
+ userAgent?: string;
122
+ severity?: EventSeverity;
123
+ category?: EventCategory;
124
+ ruleId?: string;
125
+ threatDetected?: boolean;
126
+ /** Recent requests leading up to this event — only populated on errors. */
127
+ breadcrumbs?: Breadcrumb[];
128
+
129
+ requestId?: string;
130
+ correlationId?: string;
131
+ traceId?: string;
132
+ spanId?: string;
133
+ /** Route pattern when the framework exposes one, e.g. `/users/:id`. */
134
+ routeTemplate?: string;
135
+
136
+ request?: CapturedMessage;
137
+ response?: CapturedMessage;
138
+ /** The API a proxied request was forwarded to. Only set in proxy mode. */
139
+ destination?: { url: string };
140
+ /** Machine-readable failure code, e.g. `ECONNREFUSED` for an unreachable destination. */
141
+ errorCode?: string;
142
+ /** Set when the client disconnected before the response finished. */
143
+ aborted?: boolean;
144
+ integration?: "express" | "node-http" | "proxy" | "manual";
145
+ }