midline-agent 0.1.9 → 0.3.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/redact.ts ADDED
@@ -0,0 +1,231 @@
1
+ /**
2
+ * Redaction happens in the host process, before an event is queued. Whatever is
3
+ * removed here never reaches a socket, a log line or the Midline server.
4
+ *
5
+ * Matching is on a normalised key — lower-cased with punctuation stripped — so
6
+ * `X-API-Key`, `api_key` and `apiKey` are all the same key.
7
+ */
8
+
9
+ export const REDACTED = "[REDACTED]";
10
+
11
+ /** Substrings: any key containing one of these is sensitive. */
12
+ const SENSITIVE_KEY_PARTS = [
13
+ "password",
14
+ "passwd",
15
+ "passphrase",
16
+ "secret",
17
+ "token",
18
+ "apikey",
19
+ "accesskey",
20
+ "privatekey",
21
+ "authorization",
22
+ "cookie",
23
+ "session",
24
+ "credential",
25
+ "csrf",
26
+ "xsrf",
27
+ "signature",
28
+ "creditcard",
29
+ "cardnumber",
30
+ "cvv",
31
+ "cvc",
32
+ "ssn",
33
+ "socialsecurity",
34
+ ];
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
+
39
+ /** Always redacted by name, even if a user-supplied list somehow unmatched them. */
40
+ export const DEFAULT_SENSITIVE_HEADERS = [
41
+ "authorization",
42
+ "proxy-authorization",
43
+ "cookie",
44
+ "set-cookie",
45
+ "x-api-key",
46
+ "api-key",
47
+ "x-auth-token",
48
+ "x-access-token",
49
+ "x-refresh-token",
50
+ "x-csrf-token",
51
+ "x-xsrf-token",
52
+ "x-amz-security-token",
53
+ ];
54
+
55
+ const VALUE_PATTERNS: Array<[RegExp, string]> = [
56
+ [/-----BEGIN [A-Z ]*PRIVATE KEY-----[\s\S]*?-----END [A-Z ]*PRIVATE KEY-----/g, REDACTED],
57
+ [/\b(Bearer|Basic|Digest|Token)\s+[A-Za-z0-9._~+\/=-]{8,}/gi, `$1 ${REDACTED}`],
58
+ [/\beyJ[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{4,}/g, REDACTED],
59
+ [/\bak_[A-Fa-f0-9]{16,}\b/g, REDACTED],
60
+ [/\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{10,}\b/g, REDACTED],
61
+ [/\bAKIA[0-9A-Z]{16}\b/g, REDACTED],
62
+ [/(\b[a-z][a-z0-9+.-]*:\/\/)[^\s\/@:]+:[^\s\/@]+@/gi, `$1${REDACTED}@`],
63
+ ];
64
+
65
+ /** `key=value` / `key: value` in query strings, log lines and error messages. */
66
+ const KEY_VALUE_PAIR = /(^|[?&;,\s(\[{])([A-Za-z0-9_.%-]{1,64})(\s*[=:]\s*)("[^"]*"|'[^']*'|[^\s&#,;)\]}]+)/g;
67
+ /** `"key": value` in JSON text, including JSON that was cut off mid-value. */
68
+ const JSON_PAIR = /"([^"\\]{1,100})"\s*:\s*("(?:[^"\\]|\\.)*"?|-?\d+(?:\.\d+)?|true|false|null)/g;
69
+
70
+ export function normalizeKey(key: string): string {
71
+ return key.toLowerCase().replace(/[^a-z0-9]/g, "");
72
+ }
73
+
74
+ export class Redactor {
75
+ private readonly extraKeys: string[];
76
+ private readonly headerNames: Set<string>;
77
+
78
+ constructor(extraFields: string[] = [], extraHeaders: string[] = []) {
79
+ this.extraKeys = extraFields.map(normalizeKey).filter(Boolean);
80
+ this.headerNames = new Set(
81
+ [...DEFAULT_SENSITIVE_HEADERS, ...extraHeaders].map((name) => name.toLowerCase()),
82
+ );
83
+ }
84
+
85
+ isSensitiveKey(key: string): boolean {
86
+ const normalized = normalizeKey(key);
87
+ if (!normalized) return false;
88
+ if (SENSITIVE_KEYS_EXACT.has(normalized)) return true;
89
+ if (SENSITIVE_KEY_PARTS.some((part) => normalized.includes(part))) return true;
90
+ return this.extraKeys.some((part) => normalized.includes(part));
91
+ }
92
+
93
+ /** Masks credentials embedded in free text: bearer tokens, JWTs, key formats, URL userinfo and query params. */
94
+ string(value: string, maxLength = 2048): string {
95
+ let out = value.length > maxLength * 4 ? value.slice(0, maxLength * 4) : value;
96
+ for (const [pattern, replacement] of VALUE_PATTERNS) {
97
+ out = out.replace(pattern, replacement);
98
+ }
99
+ if (out.includes("=") || out.includes(":")) {
100
+ out = out.replace(KEY_VALUE_PAIR, (match, sep: string, key: string, delimiter: string) => {
101
+ let decoded = key;
102
+ try {
103
+ decoded = decodeURIComponent(key);
104
+ } catch {
105
+ // keep the raw key
106
+ }
107
+ return this.isSensitiveKey(decoded) ? `${sep}${key}${delimiter}${REDACTED}` : match;
108
+ });
109
+ }
110
+ if (out.includes('"')) {
111
+ out = out.replace(JSON_PAIR, (match, key: string) =>
112
+ this.isSensitiveKey(key) ? `"${key}":"${REDACTED}"` : match,
113
+ );
114
+ }
115
+ // The ellipsis counts toward the limit: servers validate these lengths exactly.
116
+ return out.length > maxLength ? `${out.slice(0, Math.max(0, maxLength - 1))}…` : out;
117
+ }
118
+
119
+ /** Deep copy with sensitive keys and values masked. Bounded in depth, breadth and string length. */
120
+ value(input: unknown, depth = 0, seen: WeakSet<object> = new WeakSet()): unknown {
121
+ if (input === null || input === undefined) return input;
122
+ if (typeof input === "string") return this.string(input);
123
+ if (typeof input === "number" || typeof input === "boolean") return input;
124
+ if (typeof input === "bigint") return input.toString();
125
+ if (typeof input === "function" || typeof input === "symbol") return undefined;
126
+ if (input instanceof Date) return Number.isNaN(input.getTime()) ? null : input.toISOString();
127
+ if (Buffer.isBuffer(input) || ArrayBuffer.isView(input)) {
128
+ return `[Binary ${(input as ArrayBufferView).byteLength} bytes]`;
129
+ }
130
+ if (typeof input !== "object") return undefined;
131
+
132
+ if (seen.has(input)) return "[Circular]";
133
+ if (depth >= 8) return "[Truncated]";
134
+ seen.add(input);
135
+
136
+ if (Array.isArray(input)) {
137
+ const items = input.slice(0, 100).map((item) => this.value(item, depth + 1, seen));
138
+ if (input.length > 100) items.push(`[${input.length - 100} more]`);
139
+ return items;
140
+ }
141
+
142
+ const out: Record<string, unknown> = {};
143
+ let count = 0;
144
+ for (const [key, nested] of Object.entries(input as Record<string, unknown>)) {
145
+ if (count++ >= 200) {
146
+ out["[truncated]"] = "too many keys";
147
+ break;
148
+ }
149
+ out[key] = this.isSensitiveKey(key) ? REDACTED : this.value(nested, depth + 1, seen);
150
+ }
151
+ return out;
152
+ }
153
+
154
+ headers(headers: Record<string, unknown> | undefined): Record<string, string> | undefined {
155
+ if (!headers) return undefined;
156
+ const out: Record<string, string> = {};
157
+ for (const [rawName, rawValue] of Object.entries(headers)) {
158
+ if (rawValue === undefined) continue;
159
+ const name = rawName.toLowerCase();
160
+ if (this.headerNames.has(name) || this.isSensitiveKey(name)) {
161
+ out[name] = REDACTED;
162
+ continue;
163
+ }
164
+ const joined = Array.isArray(rawValue) ? rawValue.join(", ") : String(rawValue);
165
+ out[name] = this.string(joined, 1024);
166
+ }
167
+ return out;
168
+ }
169
+
170
+ query(search: string): Record<string, string | string[]> | undefined {
171
+ if (!search) return undefined;
172
+ const out: Record<string, string | string[]> = {};
173
+ const params = new URLSearchParams(search.startsWith("?") ? search.slice(1) : search);
174
+ for (const key of new Set(params.keys())) {
175
+ const values = params.getAll(key).map((value) =>
176
+ this.isSensitiveKey(key) ? REDACTED : this.string(value, 512),
177
+ );
178
+ out[key] = values.length === 1 ? values[0] : values;
179
+ }
180
+ return out;
181
+ }
182
+
183
+ /**
184
+ * Redacts a captured body. Structured content is parsed and redacted by key;
185
+ * text that cannot be parsed (usually because it was truncated) gets key/value
186
+ * pattern masking instead, so a cut-off JSON body still loses its passwords.
187
+ */
188
+ body(raw: unknown, contentType: string | undefined, maxBytes: number): { body?: unknown; truncated?: boolean; omitted?: string } {
189
+ if (raw === undefined || raw === null || maxBytes <= 0) return {};
190
+
191
+ const type = (contentType || "").toLowerCase();
192
+
193
+ if (typeof raw === "object" && !Buffer.isBuffer(raw)) {
194
+ return this.fit(this.value(raw), maxBytes);
195
+ }
196
+
197
+ const text = Buffer.isBuffer(raw) ? raw.toString("utf8") : String(raw);
198
+
199
+ if (type.includes("json")) {
200
+ try {
201
+ return this.fit(this.value(JSON.parse(text)), maxBytes);
202
+ } catch {
203
+ return this.cut(this.string(text, text.length), maxBytes);
204
+ }
205
+ }
206
+ if (type.includes("application/x-www-form-urlencoded")) {
207
+ return this.fit(this.query(text) ?? {}, maxBytes);
208
+ }
209
+ if (!type || type.startsWith("text/") || type.includes("xml") || type.includes("graphql")) {
210
+ return this.cut(this.string(text, maxBytes * 2), maxBytes);
211
+ }
212
+ return { omitted: `content-type ${type.split(";")[0]}` };
213
+ }
214
+
215
+ private fit(value: unknown, maxBytes: number): { body?: unknown; truncated?: boolean } {
216
+ const serialized = JSON.stringify(value) ?? "";
217
+ if (Buffer.byteLength(serialized) <= maxBytes) {
218
+ return { body: value };
219
+ }
220
+ return this.cut(serialized, maxBytes);
221
+ }
222
+
223
+ private cut(text: string, maxBytes: number): { body?: unknown; truncated?: boolean } {
224
+ const bytes = Buffer.from(text);
225
+ if (bytes.length <= maxBytes) {
226
+ return { body: text };
227
+ }
228
+ // Slicing bytes can split a multi-byte character; the replacement char is harmless here.
229
+ return { body: bytes.subarray(0, maxBytes).toString("utf8"), truncated: true };
230
+ }
231
+ }
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,152 @@
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" | "console";
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
+ * Also send what the process prints — `console.log`, Nest's logger, anything
67
+ * written to stdout or stderr — as `console` events, one per line, redacted like
68
+ * any other text. Initialise the agent before creating the app to include its
69
+ * startup lines. Off by default. Env fallback: `MIDLINE_CAPTURE_CONSOLE`.
70
+ */
71
+ captureConsole?: boolean;
72
+
73
+ /** Set to false to keep the agent inert. Env fallback: `MIDLINE_ENABLED`. */
74
+ enabled?: boolean;
75
+
76
+ /**
77
+ * Receives the agent's own diagnostics so they can go to your logger instead of
78
+ * the console. Set `debug` too if you want them in both places.
79
+ */
80
+ onError?: (message: string) => void;
81
+ /** Emit every diagnostic instead of one per distinct fault. Env fallback: `MIDLINE_DEBUG`. */
82
+ debug?: boolean;
83
+
84
+ /** How often the queue is flushed, in ms. Default 1500. */
85
+ flushIntervalMs?: number;
86
+ /** Deadline for a whole delivery request, in ms. Default 10000. */
87
+ timeoutMs?: number;
88
+ /** Deadline for the TCP + TLS handshake, in ms. Default 5000. */
89
+ connectTimeoutMs?: number;
90
+ /** Events per delivery request. Default 100, max 500. */
91
+ maxBatchSize?: number;
92
+ /** Events buffered while the Midline server is unreachable. Oldest are dropped past this. Default 1000. */
93
+ maxQueueSize?: number;
94
+ /** Serialised size cap for a single event; larger events lose their captured bodies first. Default and maximum 65536. */
95
+ maxEventBytes?: number;
96
+ /** Upper bound on retry backoff, in ms. Default 300000. */
97
+ maxRetryDelayMs?: number;
98
+ }
99
+
100
+ export interface Breadcrumb {
101
+ type: string;
102
+ message: string;
103
+ timestamp: string;
104
+ }
105
+
106
+ export interface CapturedMessage {
107
+ headers?: Record<string, string>;
108
+ query?: Record<string, string | string[]>;
109
+ body?: unknown;
110
+ /** Bytes seen on the wire (or serialised), before truncation. */
111
+ bodyBytes?: number;
112
+ truncated?: boolean;
113
+ /** Why a body was not captured, e.g. a binary content type. */
114
+ omitted?: string;
115
+ }
116
+
117
+ export interface MidlineEvent {
118
+ type: EventType;
119
+ /** ISO timestamp. Defaults to the moment the event is added. */
120
+ timestamp?: string;
121
+ path: string;
122
+ method?: string;
123
+ statusCode?: number;
124
+ duration?: number;
125
+ message?: string;
126
+ stack?: string;
127
+ ip?: string;
128
+ userAgent?: string;
129
+ severity?: EventSeverity;
130
+ category?: EventCategory;
131
+ ruleId?: string;
132
+ threatDetected?: boolean;
133
+ /** Recent requests leading up to this event — only populated on errors. */
134
+ breadcrumbs?: Breadcrumb[];
135
+
136
+ requestId?: string;
137
+ correlationId?: string;
138
+ traceId?: string;
139
+ spanId?: string;
140
+ /** Route pattern when the framework exposes one, e.g. `/users/:id`. */
141
+ routeTemplate?: string;
142
+
143
+ request?: CapturedMessage;
144
+ response?: CapturedMessage;
145
+ /** The API a proxied request was forwarded to. Only set in proxy mode. */
146
+ destination?: { url: string };
147
+ /** Machine-readable failure code, e.g. `ECONNREFUSED` for an unreachable destination. */
148
+ errorCode?: string;
149
+ /** Set when the client disconnected before the response finished. */
150
+ aborted?: boolean;
151
+ integration?: "express" | "node-http" | "proxy" | "console" | "manual";
152
+ }