midline-agent 0.1.9 → 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/README.md +288 -252
- package/dist/agent.d.ts +105 -6
- package/dist/agent.js +663 -102
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +63 -0
- package/dist/config.d.ts +63 -0
- package/dist/config.js +230 -0
- package/dist/context.d.ts +13 -0
- package/dist/context.js +38 -0
- package/dist/errorHandler.d.ts +11 -2
- package/dist/errorHandler.js +32 -12
- package/dist/index.d.ts +9 -2
- package/dist/index.js +9 -1
- package/dist/middleware.d.ts +19 -2
- package/dist/middleware.js +92 -41
- package/dist/proxy.d.ts +70 -0
- package/dist/proxy.js +383 -0
- package/dist/redact.d.ts +35 -0
- package/dist/redact.js +223 -0
- package/dist/tap.d.ts +18 -0
- package/dist/tap.js +62 -0
- package/dist/transport.d.ts +35 -0
- package/dist/transport.js +133 -0
- package/dist/types.d.ts +107 -5
- package/package.json +13 -8
- package/src/agent.ts +734 -102
- package/src/cli.ts +69 -0
- package/src/config.ts +232 -0
- package/src/context.ts +48 -0
- package/src/errorHandler.ts +36 -13
- package/src/index.ts +19 -2
- package/src/middleware.ts +118 -43
- package/src/proxy.ts +435 -0
- package/src/redact.ts +231 -0
- package/src/tap.ts +55 -0
- package/src/transport.ts +125 -0
- package/src/types.ts +144 -31
- package/test/agent.test.js +353 -0
- package/test/helpers.js +151 -0
- package/test/middleware.test.js +178 -0
- package/test/proxy.test.js +274 -0
- package/test/redact.test.js +105 -0
package/src/cli.ts
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { MidlineAgent } from "./agent";
|
|
3
|
+
import { ConfigError } from "./config";
|
|
4
|
+
import { proxyAddress, resolveTarget, startMidlineProxy } from "./proxy";
|
|
5
|
+
|
|
6
|
+
const USAGE = `Usage: midline-agent proxy [--target <url>] [--port <n>] [--host <addr>]
|
|
7
|
+
|
|
8
|
+
Forwards traffic to a destination API and reports every exchange to Midline.
|
|
9
|
+
|
|
10
|
+
--target Destination API (TARGET_API_URL, required)
|
|
11
|
+
--port Port to listen on (MIDLINE_PROXY_PORT, default 8080)
|
|
12
|
+
--host Interface to bind (MIDLINE_PROXY_HOST, default 127.0.0.1)
|
|
13
|
+
|
|
14
|
+
Midline server:
|
|
15
|
+
MIDLINE_API_KEY project API key (without it, traffic is forwarded but not reported)
|
|
16
|
+
MIDLINE_ENDPOINT default https://api.usemidline.com
|
|
17
|
+
MIDLINE_CUSTOM_CA extra CA for the Midline server only (PEM or file path)
|
|
18
|
+
TARGET_API_CA extra CA for the destination only (PEM or file path)
|
|
19
|
+
`;
|
|
20
|
+
|
|
21
|
+
function flag(args: string[], name: string): string | undefined {
|
|
22
|
+
const index = args.indexOf(`--${name}`);
|
|
23
|
+
if (index !== -1) return args[index + 1];
|
|
24
|
+
const inline = args.find((arg) => arg.startsWith(`--${name}=`));
|
|
25
|
+
return inline?.slice(name.length + 3);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function main(): Promise<void> {
|
|
29
|
+
const args = process.argv.slice(2);
|
|
30
|
+
if (args[0] !== "proxy" || args.includes("--help") || args.includes("-h")) {
|
|
31
|
+
process.stdout.write(USAGE);
|
|
32
|
+
process.exitCode = args[0] === "proxy" || args.length === 0 ? 0 : 1;
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const port = flag(args, "port");
|
|
37
|
+
const target = flag(args, "target") ?? process.env.TARGET_API_URL;
|
|
38
|
+
// Fail on the destination before the agent starts talking about its own config.
|
|
39
|
+
resolveTarget(target);
|
|
40
|
+
|
|
41
|
+
const agent = MidlineAgent.init({ serviceName: process.env.MIDLINE_SERVICE_NAME });
|
|
42
|
+
const server = await startMidlineProxy({
|
|
43
|
+
target,
|
|
44
|
+
port: port ? Number(port) : undefined,
|
|
45
|
+
host: flag(args, "host"),
|
|
46
|
+
agent,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
process.stdout.write(
|
|
50
|
+
`midline proxy listening on ${proxyAddress(server)} -> ${server.proxy.target.origin}${server.proxy.target.pathname}` +
|
|
51
|
+
`${agent.active ? "" : " (monitoring off)"}\n`,
|
|
52
|
+
);
|
|
53
|
+
|
|
54
|
+
let stopping = false;
|
|
55
|
+
const stop = (signal: string) => {
|
|
56
|
+
if (stopping) return;
|
|
57
|
+
stopping = true;
|
|
58
|
+
process.stdout.write(`midline proxy: ${signal}, draining\n`);
|
|
59
|
+
server.close();
|
|
60
|
+
void agent.shutdown(5000).finally(() => process.exit(0));
|
|
61
|
+
};
|
|
62
|
+
process.on("SIGINT", () => stop("SIGINT"));
|
|
63
|
+
process.on("SIGTERM", () => stop("SIGTERM"));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
main().catch((err) => {
|
|
67
|
+
process.stderr.write(`midline proxy: ${err instanceof ConfigError ? err.message : err?.stack ?? err}\n`);
|
|
68
|
+
process.exit(1);
|
|
69
|
+
});
|
package/src/config.ts
ADDED
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { readFileSync } from "fs";
|
|
2
|
+
import { X509Certificate } from "crypto";
|
|
3
|
+
import * as tls from "tls";
|
|
4
|
+
import { CaInput, CaptureOptions, MidlineConfig } from "./types";
|
|
5
|
+
|
|
6
|
+
export const DEFAULT_ENDPOINT = "https://api.usemidline.com";
|
|
7
|
+
export const INGEST_PATH = "/api/api-monitor/events";
|
|
8
|
+
|
|
9
|
+
/** A setting that cannot work as given. Thrown at construction, never while serving traffic. */
|
|
10
|
+
export class ConfigError extends Error {
|
|
11
|
+
constructor(message: string) {
|
|
12
|
+
super(message);
|
|
13
|
+
this.name = "ConfigError";
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface ResolvedCapture {
|
|
18
|
+
headers: boolean;
|
|
19
|
+
query: boolean;
|
|
20
|
+
requestBody: boolean;
|
|
21
|
+
responseBody: boolean;
|
|
22
|
+
maxBodyBytes: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface ResolvedConfig {
|
|
26
|
+
apiKey: string;
|
|
27
|
+
serviceName?: string;
|
|
28
|
+
ingestUrl: URL;
|
|
29
|
+
batchUrl: URL;
|
|
30
|
+
/** Full trust store for the Midline endpoint, or undefined for Node's default. */
|
|
31
|
+
ca?: Array<string | Buffer>;
|
|
32
|
+
hasCustomCa: boolean;
|
|
33
|
+
environment?: string;
|
|
34
|
+
host?: string;
|
|
35
|
+
region?: string;
|
|
36
|
+
release?: string;
|
|
37
|
+
redactFields: string[];
|
|
38
|
+
redactHeaders: string[];
|
|
39
|
+
capture: ResolvedCapture;
|
|
40
|
+
onError?: (message: string) => void;
|
|
41
|
+
debug: boolean;
|
|
42
|
+
flushIntervalMs: number;
|
|
43
|
+
timeoutMs: number;
|
|
44
|
+
connectTimeoutMs: number;
|
|
45
|
+
maxBatchSize: number;
|
|
46
|
+
maxQueueSize: number;
|
|
47
|
+
maxEventBytes: number;
|
|
48
|
+
maxRetryDelayMs: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function env(name: string): string | undefined {
|
|
52
|
+
if (typeof process === "undefined" || !process.env) {
|
|
53
|
+
return undefined;
|
|
54
|
+
}
|
|
55
|
+
const value = process.env[name];
|
|
56
|
+
return value === undefined || value.trim() === "" ? undefined : value.trim();
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export function envFlag(name: string): boolean | undefined {
|
|
60
|
+
const value = env(name)?.toLowerCase();
|
|
61
|
+
if (value === undefined) return undefined;
|
|
62
|
+
if (["1", "true", "yes", "on"].includes(value)) return true;
|
|
63
|
+
if (["0", "false", "no", "off"].includes(value)) return false;
|
|
64
|
+
return undefined;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function envInt(name: string): number | undefined {
|
|
68
|
+
const value = env(name);
|
|
69
|
+
if (value === undefined) return undefined;
|
|
70
|
+
const parsed = Number(value);
|
|
71
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function isLoopback(hostname: string): boolean {
|
|
75
|
+
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
76
|
+
return (
|
|
77
|
+
host === "localhost" ||
|
|
78
|
+
host.endsWith(".localhost") ||
|
|
79
|
+
host === "::1" ||
|
|
80
|
+
/^127(\.\d{1,3}){3}$/.test(host)
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Turns whatever the user configured into the ingest URL.
|
|
86
|
+
*
|
|
87
|
+
* `https://api.usemidline.com`, `https://api.usemidline.com/api/api-monitor/events`
|
|
88
|
+
* and `https://gateway.internal/midline` all work; the last is treated as a prefix.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveIngestUrl(endpoint: string): URL {
|
|
91
|
+
let url: URL;
|
|
92
|
+
try {
|
|
93
|
+
url = new URL(endpoint);
|
|
94
|
+
} catch {
|
|
95
|
+
throw new ConfigError(`endpoint "${endpoint}" is not a valid URL`);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
99
|
+
throw new ConfigError(`endpoint must use https:// (got ${url.protocol}//)`);
|
|
100
|
+
}
|
|
101
|
+
if (url.protocol === "http:" && !isLoopback(url.hostname)) {
|
|
102
|
+
throw new ConfigError(
|
|
103
|
+
`refusing to send the API key in cleartext to ${url.origin} — use https://. ` +
|
|
104
|
+
"Plain http:// is only accepted for localhost.",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
if (url.username || url.password) {
|
|
108
|
+
throw new ConfigError("endpoint must not contain credentials; pass the key as apiKey");
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
url.search = "";
|
|
112
|
+
url.hash = "";
|
|
113
|
+
|
|
114
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
115
|
+
if (path.endsWith(`${INGEST_PATH}/batch`)) {
|
|
116
|
+
url.pathname = path.slice(0, -"/batch".length);
|
|
117
|
+
} else if (path.endsWith(INGEST_PATH)) {
|
|
118
|
+
url.pathname = path;
|
|
119
|
+
} else {
|
|
120
|
+
url.pathname = `${path}${INGEST_PATH}`;
|
|
121
|
+
}
|
|
122
|
+
return url;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Loads extra CAs. Every entry must contain at least one parseable certificate, so a
|
|
127
|
+
* typo in a path fails loudly at startup instead of silently trusting nothing.
|
|
128
|
+
*/
|
|
129
|
+
export function loadCa(input: CaInput | undefined, label: string): Buffer[] | undefined {
|
|
130
|
+
if (input === undefined || input === null || (Array.isArray(input) && input.length === 0)) {
|
|
131
|
+
return undefined;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const entries = Array.isArray(input) ? input : [input];
|
|
135
|
+
return entries.map((entry, index) => {
|
|
136
|
+
let pem: Buffer;
|
|
137
|
+
if (Buffer.isBuffer(entry)) {
|
|
138
|
+
pem = entry;
|
|
139
|
+
} else if (typeof entry === "string" && entry.includes("-----BEGIN")) {
|
|
140
|
+
pem = Buffer.from(entry);
|
|
141
|
+
} else if (typeof entry === "string") {
|
|
142
|
+
try {
|
|
143
|
+
pem = readFileSync(entry);
|
|
144
|
+
} catch (err: any) {
|
|
145
|
+
throw new ConfigError(`${label}: cannot read CA file "${entry}" (${err?.code ?? err?.message})`);
|
|
146
|
+
}
|
|
147
|
+
} else {
|
|
148
|
+
throw new ConfigError(`${label}: entry ${index} must be a PEM string, a Buffer or a file path`);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if (!pem.toString("utf8").includes("-----BEGIN CERTIFICATE-----")) {
|
|
152
|
+
throw new ConfigError(`${label}: entry ${index} does not contain a PEM certificate`);
|
|
153
|
+
}
|
|
154
|
+
try {
|
|
155
|
+
new X509Certificate(pem);
|
|
156
|
+
} catch {
|
|
157
|
+
throw new ConfigError(`${label}: entry ${index} is not a parseable certificate`);
|
|
158
|
+
}
|
|
159
|
+
return pem;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Node's `ca` option *replaces* the default trust store. Passing only a private CA
|
|
165
|
+
* would make every public certificate untrusted, so extra CAs are appended to the
|
|
166
|
+
* defaults (including NODE_EXTRA_CA_CERTS where the runtime exposes them).
|
|
167
|
+
*/
|
|
168
|
+
export function trustStore(extra: Buffer[] | undefined): Array<string | Buffer> | undefined {
|
|
169
|
+
if (!extra || extra.length === 0) {
|
|
170
|
+
return undefined;
|
|
171
|
+
}
|
|
172
|
+
const getCACertificates = (tls as any).getCACertificates as
|
|
173
|
+
| ((type?: string) => string[])
|
|
174
|
+
| undefined;
|
|
175
|
+
const defaults = typeof getCACertificates === "function"
|
|
176
|
+
? getCACertificates("default")
|
|
177
|
+
: [...tls.rootCertificates];
|
|
178
|
+
return [...defaults, ...extra];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
function positiveInt(value: number | undefined, fallback: number, min: number, max: number): number {
|
|
182
|
+
if (value === undefined || !Number.isFinite(value)) {
|
|
183
|
+
return fallback;
|
|
184
|
+
}
|
|
185
|
+
return Math.min(Math.max(Math.floor(value), min), max);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function resolveCapture(capture: CaptureOptions | undefined): ResolvedCapture {
|
|
189
|
+
return {
|
|
190
|
+
headers: capture?.headers === true,
|
|
191
|
+
query: capture?.query === true,
|
|
192
|
+
requestBody: capture?.requestBody === true,
|
|
193
|
+
responseBody: capture?.responseBody === true,
|
|
194
|
+
maxBodyBytes: positiveInt(capture?.maxBodyBytes, 4096, 0, 1024 * 1024),
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function resolveConfig(config: MidlineConfig): ResolvedConfig {
|
|
199
|
+
const apiKey = (config.apiKey ?? env("MIDLINE_API_KEY") ?? "").trim();
|
|
200
|
+
const ingestUrl = resolveIngestUrl(config.endpoint || env("MIDLINE_ENDPOINT") || DEFAULT_ENDPOINT);
|
|
201
|
+
const extraCa = loadCa(config.ca ?? env("MIDLINE_CUSTOM_CA"), "ca / MIDLINE_CUSTOM_CA");
|
|
202
|
+
|
|
203
|
+
if (extraCa && ingestUrl.protocol !== "https:") {
|
|
204
|
+
throw new ConfigError("ca / MIDLINE_CUSTOM_CA is set but the endpoint is not https://");
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
return {
|
|
208
|
+
apiKey,
|
|
209
|
+
serviceName: config.serviceName ?? env("MIDLINE_SERVICE_NAME"),
|
|
210
|
+
ingestUrl,
|
|
211
|
+
batchUrl: new URL(`${ingestUrl.pathname}/batch`, ingestUrl),
|
|
212
|
+
ca: trustStore(extraCa),
|
|
213
|
+
hasCustomCa: Boolean(extraCa),
|
|
214
|
+
environment: config.environment ?? env("MIDLINE_ENVIRONMENT"),
|
|
215
|
+
host: config.host,
|
|
216
|
+
region: config.region,
|
|
217
|
+
release: config.release ?? env("MIDLINE_RELEASE"),
|
|
218
|
+
redactFields: [...(config.redactFields ?? []), ...(config.maskFields ?? [])],
|
|
219
|
+
redactHeaders: config.redactHeaders ?? [],
|
|
220
|
+
capture: resolveCapture(config.capture),
|
|
221
|
+
onError: config.onError,
|
|
222
|
+
debug: config.debug ?? envFlag("MIDLINE_DEBUG") ?? false,
|
|
223
|
+
flushIntervalMs: positiveInt(config.flushIntervalMs, 1500, 50, 60_000),
|
|
224
|
+
timeoutMs: positiveInt(config.timeoutMs, 10_000, 100, 120_000),
|
|
225
|
+
connectTimeoutMs: positiveInt(config.connectTimeoutMs, 5000, 50, 60_000),
|
|
226
|
+
maxBatchSize: positiveInt(config.maxBatchSize, 100, 1, 500),
|
|
227
|
+
maxQueueSize: positiveInt(config.maxQueueSize, 1000, 1, 100_000),
|
|
228
|
+
// The server caps a payload at 64 KiB; a larger event could never be accepted.
|
|
229
|
+
maxEventBytes: positiveInt(config.maxEventBytes, 64 * 1024, 1024, 64 * 1024),
|
|
230
|
+
maxRetryDelayMs: positiveInt(config.maxRetryDelayMs, 5 * 60_000, 1000, 60 * 60_000),
|
|
231
|
+
};
|
|
232
|
+
}
|
package/src/context.ts
ADDED
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { randomUUID } from "crypto";
|
|
2
|
+
import type { IncomingHttpHeaders } from "http";
|
|
3
|
+
|
|
4
|
+
export interface RequestContext {
|
|
5
|
+
/** This hop's id. Taken from `x-request-id` when the caller sent a sane one. */
|
|
6
|
+
requestId: string;
|
|
7
|
+
/** Shared across every service a request touches. Falls back to the request id. */
|
|
8
|
+
correlationId: string;
|
|
9
|
+
traceId?: string;
|
|
10
|
+
spanId?: string;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
const contexts = new WeakMap<object, RequestContext>();
|
|
14
|
+
|
|
15
|
+
/** Ids come from the network, so only short, printable ones are reused. */
|
|
16
|
+
function sanitizeId(value: string | string[] | undefined): string | undefined {
|
|
17
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
18
|
+
if (typeof raw !== "string") return undefined;
|
|
19
|
+
const trimmed = raw.trim();
|
|
20
|
+
return /^[A-Za-z0-9._:\/+=-]{1,128}$/.test(trimmed) ? trimmed : undefined;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** W3C trace context: `00-<32 hex trace id>-<16 hex parent id>-<2 hex flags>`. */
|
|
24
|
+
function parseTraceparent(value: string | string[] | undefined): { traceId: string; spanId: string } | undefined {
|
|
25
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
26
|
+
const match = typeof raw === "string"
|
|
27
|
+
? /^[\da-f]{2}-([\da-f]{32})-([\da-f]{16})-[\da-f]{2}$/.exec(raw.trim().toLowerCase())
|
|
28
|
+
: null;
|
|
29
|
+
if (!match || /^0+$/.test(match[1]) || /^0+$/.test(match[2])) {
|
|
30
|
+
return undefined;
|
|
31
|
+
}
|
|
32
|
+
return { traceId: match[1], spanId: match[2] };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function createRequestContext(headers: IncomingHttpHeaders): RequestContext {
|
|
36
|
+
const requestId = sanitizeId(headers["x-request-id"]) ?? randomUUID();
|
|
37
|
+
const correlationId = sanitizeId(headers["x-correlation-id"]) ?? requestId;
|
|
38
|
+
return { requestId, correlationId, ...parseTraceparent(headers["traceparent"]) };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function setRequestContext(req: object, context: RequestContext): void {
|
|
42
|
+
contexts.set(req, context);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** The ids Midline recorded for this request — useful for stamping your own logs. */
|
|
46
|
+
export function getRequestContext(req: object): RequestContext | undefined {
|
|
47
|
+
return contexts.get(req);
|
|
48
|
+
}
|
package/src/errorHandler.ts
CHANGED
|
@@ -1,20 +1,43 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "http";
|
|
2
2
|
import { MidlineAgent } from "./agent";
|
|
3
3
|
import { getBreadcrumbs } from "./breadcrumbs";
|
|
4
|
+
import { getRequestContext } from "./context";
|
|
4
5
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
timestamp: new Date().toISOString(),
|
|
10
|
-
path: req.path,
|
|
11
|
-
method: req.method,
|
|
12
|
-
statusCode: err.status || 500,
|
|
13
|
-
message: err.message,
|
|
14
|
-
stack: err.stack,
|
|
15
|
-
breadcrumbs: getBreadcrumbs(),
|
|
16
|
-
});
|
|
6
|
+
export interface MidlineErrorHandlerOptions {
|
|
7
|
+
/** Defaults to the agent created by `MidlineAgent.init()`. */
|
|
8
|
+
agent?: MidlineAgent;
|
|
9
|
+
}
|
|
17
10
|
|
|
11
|
+
/**
|
|
12
|
+
* Express error middleware. Records the error, then passes it on unchanged — it
|
|
13
|
+
* never sends a response itself. Register it after your routes.
|
|
14
|
+
*/
|
|
15
|
+
export function midlineErrorHandler(options: MidlineErrorHandlerOptions = {}) {
|
|
16
|
+
// Four named parameters: Express recognises error middleware by arity.
|
|
17
|
+
return function midlineError(err: any, req: IncomingMessage, _res: ServerResponse, next: (err?: unknown) => void): void {
|
|
18
|
+
try {
|
|
19
|
+
const agent = options.agent ?? MidlineAgent.current;
|
|
20
|
+
if (agent?.active) {
|
|
21
|
+
const context = getRequestContext(req);
|
|
22
|
+
const status = Number(err?.status ?? err?.statusCode);
|
|
23
|
+
agent.addEvent({
|
|
24
|
+
type: "error",
|
|
25
|
+
path: (req as IncomingMessage & { originalUrl?: string }).originalUrl ?? req.url ?? "/",
|
|
26
|
+
method: req.method,
|
|
27
|
+
statusCode: Number.isInteger(status) && status >= 400 && status <= 599 ? status : 500,
|
|
28
|
+
message: typeof err?.message === "string" ? err.message : String(err),
|
|
29
|
+
stack: typeof err?.stack === "string" ? err.stack : undefined,
|
|
30
|
+
breadcrumbs: getBreadcrumbs(),
|
|
31
|
+
requestId: context?.requestId,
|
|
32
|
+
correlationId: context?.correlationId,
|
|
33
|
+
traceId: context?.traceId,
|
|
34
|
+
spanId: context?.spanId,
|
|
35
|
+
integration: "express",
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
// Reporting must not replace the application's own error handling.
|
|
40
|
+
}
|
|
18
41
|
next(err);
|
|
19
42
|
};
|
|
20
43
|
}
|
package/src/index.ts
CHANGED
|
@@ -1,5 +1,22 @@
|
|
|
1
|
-
export { MidlineAgent } from "./agent";
|
|
1
|
+
export { MidlineAgent, SDK_VERSION } from "./agent";
|
|
2
2
|
export { midlineMiddleware } from "./middleware";
|
|
3
|
+
export type { MidlineMiddlewareOptions } from "./middleware";
|
|
3
4
|
export { midlineErrorHandler } from "./errorHandler";
|
|
5
|
+
export type { MidlineErrorHandlerOptions } from "./errorHandler";
|
|
6
|
+
export { createMidlineProxy, startMidlineProxy } from "./proxy";
|
|
7
|
+
export type { MidlineProxy, MidlineProxyOptions, StartProxyOptions } from "./proxy";
|
|
4
8
|
export { addBreadcrumb } from "./breadcrumbs";
|
|
5
|
-
export
|
|
9
|
+
export { getRequestContext } from "./context";
|
|
10
|
+
export type { RequestContext } from "./context";
|
|
11
|
+
export { ConfigError } from "./config";
|
|
12
|
+
export type {
|
|
13
|
+
Breadcrumb,
|
|
14
|
+
CaInput,
|
|
15
|
+
CapturedMessage,
|
|
16
|
+
CaptureOptions,
|
|
17
|
+
EventCategory,
|
|
18
|
+
EventSeverity,
|
|
19
|
+
EventType,
|
|
20
|
+
MidlineConfig,
|
|
21
|
+
MidlineEvent,
|
|
22
|
+
} from "./types";
|
package/src/middleware.ts
CHANGED
|
@@ -1,62 +1,137 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "http";
|
|
2
2
|
import { MidlineAgent } from "./agent";
|
|
3
3
|
import { addBreadcrumb } from "./breadcrumbs";
|
|
4
|
+
import { ResolvedCapture, resolveCapture } from "./config";
|
|
5
|
+
import { createRequestContext, setRequestContext } from "./context";
|
|
6
|
+
import { BodyTap, headerValue, isCompressed } from "./tap";
|
|
7
|
+
import { CaptureOptions } from "./types";
|
|
4
8
|
|
|
5
|
-
export
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
+
export interface MidlineMiddlewareOptions {
|
|
10
|
+
/** Defaults to the agent created by `MidlineAgent.init()`, looked up per request. */
|
|
11
|
+
agent?: MidlineAgent;
|
|
12
|
+
/** Overrides the agent's `capture` settings for requests seen by this middleware. */
|
|
13
|
+
capture?: CaptureOptions;
|
|
14
|
+
/** Return true to leave a request out, e.g. health checks. */
|
|
15
|
+
ignore?: (req: IncomingMessage) => boolean;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** The Express additions this reads when they exist. Plain `http` requests work too. */
|
|
19
|
+
type RequestLike = IncomingMessage & {
|
|
20
|
+
originalUrl?: string;
|
|
21
|
+
ip?: string;
|
|
22
|
+
body?: unknown;
|
|
23
|
+
baseUrl?: string;
|
|
24
|
+
route?: { path?: unknown };
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* Records every request that passes through. Mount it before your routes.
|
|
29
|
+
*
|
|
30
|
+
* It only listens: nothing here reads the request stream, delays `next()`, or
|
|
31
|
+
* changes the response. Recording happens on `finish`/`close`, after the response
|
|
32
|
+
* has been handed to the socket.
|
|
33
|
+
*/
|
|
34
|
+
export function midlineMiddleware(options: MidlineMiddlewareOptions = {}) {
|
|
35
|
+
const captureOverride = options.capture ? resolveCapture(options.capture) : undefined;
|
|
36
|
+
|
|
37
|
+
return function midline(req: IncomingMessage, res: ServerResponse, next: (err?: unknown) => void): void {
|
|
38
|
+
try {
|
|
39
|
+
observe(req as RequestLike, res, options, captureOverride);
|
|
40
|
+
} catch {
|
|
41
|
+
// Monitoring must never be the reason a request fails.
|
|
42
|
+
}
|
|
43
|
+
next();
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function observe(
|
|
48
|
+
req: RequestLike,
|
|
49
|
+
res: ServerResponse,
|
|
50
|
+
options: MidlineMiddlewareOptions,
|
|
51
|
+
captureOverride: ResolvedCapture | undefined,
|
|
52
|
+
): void {
|
|
53
|
+
const agent = options.agent ?? MidlineAgent.current;
|
|
54
|
+
if (!agent || !agent.active || options.ignore?.(req)) {
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
9
57
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
58
|
+
const capture = captureOverride ?? agent.capture!;
|
|
59
|
+
const context = createRequestContext(req.headers);
|
|
60
|
+
setRequestContext(req, context);
|
|
13
61
|
|
|
14
|
-
|
|
15
|
-
|
|
62
|
+
const started = process.hrtime.bigint();
|
|
63
|
+
// originalUrl survives router rewrites of req.url; read it now, not at finish.
|
|
64
|
+
const url = req.originalUrl ?? req.url ?? "/";
|
|
65
|
+
const responseTap = capture.responseBody ? tapResponse(res, capture.maxBodyBytes) : undefined;
|
|
66
|
+
|
|
67
|
+
let recorded = false;
|
|
68
|
+
const record = () => {
|
|
69
|
+
if (recorded) return;
|
|
70
|
+
recorded = true;
|
|
71
|
+
try {
|
|
72
|
+
const aborted = !res.writableFinished;
|
|
73
|
+
const statusCode = aborted && !res.headersSent ? 499 : res.statusCode;
|
|
74
|
+
const routePath = req.route?.path;
|
|
75
|
+
const encoding = headerValue(res.getHeader("content-encoding"));
|
|
16
76
|
|
|
17
77
|
addBreadcrumb({
|
|
18
78
|
type: "http",
|
|
19
|
-
message: `${req.method} ${
|
|
79
|
+
message: `${req.method} ${url.split("?")[0]} -> ${statusCode}`,
|
|
20
80
|
timestamp: new Date().toISOString(),
|
|
21
81
|
});
|
|
22
82
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
path: req.path,
|
|
83
|
+
agent.recordHttp({
|
|
84
|
+
integration: req.originalUrl !== undefined ? "express" : "node-http",
|
|
85
|
+
context,
|
|
27
86
|
method: req.method,
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
87
|
+
url,
|
|
88
|
+
statusCode,
|
|
89
|
+
durationMs: Number(process.hrtime.bigint() - started) / 1e6,
|
|
90
|
+
ip: req.ip ?? req.socket?.remoteAddress,
|
|
91
|
+
userAgent: headerValue(req.headers["user-agent"]),
|
|
92
|
+
routeTemplate: typeof routePath === "string" ? `${req.baseUrl ?? ""}${routePath}` : undefined,
|
|
93
|
+
aborted,
|
|
94
|
+
capture,
|
|
95
|
+
request: {
|
|
96
|
+
headers: capture.headers ? req.headers : undefined,
|
|
97
|
+
contentType: headerValue(req.headers["content-type"]),
|
|
98
|
+
// Whatever a body parser already produced. The raw stream is never read here.
|
|
99
|
+
body: capture.requestBody ? req.body : undefined,
|
|
100
|
+
},
|
|
101
|
+
response: {
|
|
102
|
+
headers: capture.headers ? (res.getHeaders() as Record<string, unknown>) : undefined,
|
|
103
|
+
contentType: headerValue(res.getHeader("content-type")),
|
|
104
|
+
body: responseTap && !isCompressed(encoding) ? responseTap.body : undefined,
|
|
105
|
+
bodyBytes: responseTap?.bytes,
|
|
106
|
+
truncated: responseTap?.truncated,
|
|
107
|
+
},
|
|
32
108
|
});
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
const originalJson = res.json.bind(res);
|
|
38
|
-
const originalEnd = res.end.bind(res);
|
|
109
|
+
} catch {
|
|
110
|
+
// See above.
|
|
111
|
+
}
|
|
112
|
+
};
|
|
39
113
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
};
|
|
114
|
+
res.once("finish", record);
|
|
115
|
+
res.once("close", record);
|
|
116
|
+
}
|
|
44
117
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
118
|
+
/** Wraps write/end with every argument passed through untouched, callbacks included. */
|
|
119
|
+
function tapResponse(res: ServerResponse, limit: number): BodyTap {
|
|
120
|
+
const tap = new BodyTap(limit);
|
|
121
|
+
const write = res.write;
|
|
122
|
+
const end = res.end;
|
|
49
123
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
124
|
+
res.write = function (this: ServerResponse, ...args: any[]) {
|
|
125
|
+
tap.push(args[0], typeof args[1] === "string" ? args[1] : undefined);
|
|
126
|
+
return (write as (...a: any[]) => boolean).apply(this, args);
|
|
127
|
+
} as ServerResponse["write"];
|
|
54
128
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
129
|
+
res.end = function (this: ServerResponse, ...args: any[]) {
|
|
130
|
+
if (typeof args[0] !== "function") {
|
|
131
|
+
tap.push(args[0], typeof args[1] === "string" ? args[1] : undefined);
|
|
132
|
+
}
|
|
133
|
+
return (end as (...a: any[]) => ServerResponse).apply(this, args);
|
|
134
|
+
} as ServerResponse["end"];
|
|
59
135
|
|
|
60
|
-
|
|
61
|
-
};
|
|
136
|
+
return tap;
|
|
62
137
|
}
|