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/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,234 @@
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
+ captureConsole: boolean;
41
+ onError?: (message: string) => void;
42
+ debug: boolean;
43
+ flushIntervalMs: number;
44
+ timeoutMs: number;
45
+ connectTimeoutMs: number;
46
+ maxBatchSize: number;
47
+ maxQueueSize: number;
48
+ maxEventBytes: number;
49
+ maxRetryDelayMs: number;
50
+ }
51
+
52
+ export function env(name: string): string | undefined {
53
+ if (typeof process === "undefined" || !process.env) {
54
+ return undefined;
55
+ }
56
+ const value = process.env[name];
57
+ return value === undefined || value.trim() === "" ? undefined : value.trim();
58
+ }
59
+
60
+ export function envFlag(name: string): boolean | undefined {
61
+ const value = env(name)?.toLowerCase();
62
+ if (value === undefined) return undefined;
63
+ if (["1", "true", "yes", "on"].includes(value)) return true;
64
+ if (["0", "false", "no", "off"].includes(value)) return false;
65
+ return undefined;
66
+ }
67
+
68
+ export function envInt(name: string): number | undefined {
69
+ const value = env(name);
70
+ if (value === undefined) return undefined;
71
+ const parsed = Number(value);
72
+ return Number.isFinite(parsed) ? parsed : undefined;
73
+ }
74
+
75
+ export function isLoopback(hostname: string): boolean {
76
+ const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
77
+ return (
78
+ host === "localhost" ||
79
+ host.endsWith(".localhost") ||
80
+ host === "::1" ||
81
+ /^127(\.\d{1,3}){3}$/.test(host)
82
+ );
83
+ }
84
+
85
+ /**
86
+ * Turns whatever the user configured into the ingest URL.
87
+ *
88
+ * `https://api.usemidline.com`, `https://api.usemidline.com/api/api-monitor/events`
89
+ * and `https://gateway.internal/midline` all work; the last is treated as a prefix.
90
+ */
91
+ export function resolveIngestUrl(endpoint: string): URL {
92
+ let url: URL;
93
+ try {
94
+ url = new URL(endpoint);
95
+ } catch {
96
+ throw new ConfigError(`endpoint "${endpoint}" is not a valid URL`);
97
+ }
98
+
99
+ if (url.protocol !== "https:" && url.protocol !== "http:") {
100
+ throw new ConfigError(`endpoint must use https:// (got ${url.protocol}//)`);
101
+ }
102
+ if (url.protocol === "http:" && !isLoopback(url.hostname)) {
103
+ throw new ConfigError(
104
+ `refusing to send the API key in cleartext to ${url.origin} — use https://. ` +
105
+ "Plain http:// is only accepted for localhost.",
106
+ );
107
+ }
108
+ if (url.username || url.password) {
109
+ throw new ConfigError("endpoint must not contain credentials; pass the key as apiKey");
110
+ }
111
+
112
+ url.search = "";
113
+ url.hash = "";
114
+
115
+ const path = url.pathname.replace(/\/+$/, "");
116
+ if (path.endsWith(`${INGEST_PATH}/batch`)) {
117
+ url.pathname = path.slice(0, -"/batch".length);
118
+ } else if (path.endsWith(INGEST_PATH)) {
119
+ url.pathname = path;
120
+ } else {
121
+ url.pathname = `${path}${INGEST_PATH}`;
122
+ }
123
+ return url;
124
+ }
125
+
126
+ /**
127
+ * Loads extra CAs. Every entry must contain at least one parseable certificate, so a
128
+ * typo in a path fails loudly at startup instead of silently trusting nothing.
129
+ */
130
+ export function loadCa(input: CaInput | undefined, label: string): Buffer[] | undefined {
131
+ if (input === undefined || input === null || (Array.isArray(input) && input.length === 0)) {
132
+ return undefined;
133
+ }
134
+
135
+ const entries = Array.isArray(input) ? input : [input];
136
+ return entries.map((entry, index) => {
137
+ let pem: Buffer;
138
+ if (Buffer.isBuffer(entry)) {
139
+ pem = entry;
140
+ } else if (typeof entry === "string" && entry.includes("-----BEGIN")) {
141
+ pem = Buffer.from(entry);
142
+ } else if (typeof entry === "string") {
143
+ try {
144
+ pem = readFileSync(entry);
145
+ } catch (err: any) {
146
+ throw new ConfigError(`${label}: cannot read CA file "${entry}" (${err?.code ?? err?.message})`);
147
+ }
148
+ } else {
149
+ throw new ConfigError(`${label}: entry ${index} must be a PEM string, a Buffer or a file path`);
150
+ }
151
+
152
+ if (!pem.toString("utf8").includes("-----BEGIN CERTIFICATE-----")) {
153
+ throw new ConfigError(`${label}: entry ${index} does not contain a PEM certificate`);
154
+ }
155
+ try {
156
+ new X509Certificate(pem);
157
+ } catch {
158
+ throw new ConfigError(`${label}: entry ${index} is not a parseable certificate`);
159
+ }
160
+ return pem;
161
+ });
162
+ }
163
+
164
+ /**
165
+ * Node's `ca` option *replaces* the default trust store. Passing only a private CA
166
+ * would make every public certificate untrusted, so extra CAs are appended to the
167
+ * defaults (including NODE_EXTRA_CA_CERTS where the runtime exposes them).
168
+ */
169
+ export function trustStore(extra: Buffer[] | undefined): Array<string | Buffer> | undefined {
170
+ if (!extra || extra.length === 0) {
171
+ return undefined;
172
+ }
173
+ const getCACertificates = (tls as any).getCACertificates as
174
+ | ((type?: string) => string[])
175
+ | undefined;
176
+ const defaults = typeof getCACertificates === "function"
177
+ ? getCACertificates("default")
178
+ : [...tls.rootCertificates];
179
+ return [...defaults, ...extra];
180
+ }
181
+
182
+ function positiveInt(value: number | undefined, fallback: number, min: number, max: number): number {
183
+ if (value === undefined || !Number.isFinite(value)) {
184
+ return fallback;
185
+ }
186
+ return Math.min(Math.max(Math.floor(value), min), max);
187
+ }
188
+
189
+ export function resolveCapture(capture: CaptureOptions | undefined): ResolvedCapture {
190
+ return {
191
+ headers: capture?.headers === true,
192
+ query: capture?.query === true,
193
+ requestBody: capture?.requestBody === true,
194
+ responseBody: capture?.responseBody === true,
195
+ maxBodyBytes: positiveInt(capture?.maxBodyBytes, 4096, 0, 1024 * 1024),
196
+ };
197
+ }
198
+
199
+ export function resolveConfig(config: MidlineConfig): ResolvedConfig {
200
+ const apiKey = (config.apiKey ?? env("MIDLINE_API_KEY") ?? "").trim();
201
+ const ingestUrl = resolveIngestUrl(config.endpoint || env("MIDLINE_ENDPOINT") || DEFAULT_ENDPOINT);
202
+ const extraCa = loadCa(config.ca ?? env("MIDLINE_CUSTOM_CA"), "ca / MIDLINE_CUSTOM_CA");
203
+
204
+ if (extraCa && ingestUrl.protocol !== "https:") {
205
+ throw new ConfigError("ca / MIDLINE_CUSTOM_CA is set but the endpoint is not https://");
206
+ }
207
+
208
+ return {
209
+ apiKey,
210
+ serviceName: config.serviceName ?? env("MIDLINE_SERVICE_NAME"),
211
+ ingestUrl,
212
+ batchUrl: new URL(`${ingestUrl.pathname}/batch`, ingestUrl),
213
+ ca: trustStore(extraCa),
214
+ hasCustomCa: Boolean(extraCa),
215
+ environment: config.environment ?? env("MIDLINE_ENVIRONMENT"),
216
+ host: config.host,
217
+ region: config.region,
218
+ release: config.release ?? env("MIDLINE_RELEASE"),
219
+ redactFields: [...(config.redactFields ?? []), ...(config.maskFields ?? [])],
220
+ redactHeaders: config.redactHeaders ?? [],
221
+ capture: resolveCapture(config.capture),
222
+ captureConsole: config.captureConsole ?? envFlag("MIDLINE_CAPTURE_CONSOLE") ?? false,
223
+ onError: config.onError,
224
+ debug: config.debug ?? envFlag("MIDLINE_DEBUG") ?? false,
225
+ flushIntervalMs: positiveInt(config.flushIntervalMs, 1500, 50, 60_000),
226
+ timeoutMs: positiveInt(config.timeoutMs, 10_000, 100, 120_000),
227
+ connectTimeoutMs: positiveInt(config.connectTimeoutMs, 5000, 50, 60_000),
228
+ maxBatchSize: positiveInt(config.maxBatchSize, 100, 1, 500),
229
+ maxQueueSize: positiveInt(config.maxQueueSize, 1000, 1, 100_000),
230
+ // The server caps a payload at 64 KiB; a larger event could never be accepted.
231
+ maxEventBytes: positiveInt(config.maxEventBytes, 64 * 1024, 1024, 64 * 1024),
232
+ maxRetryDelayMs: positiveInt(config.maxRetryDelayMs, 5 * 60_000, 1000, 60 * 60_000),
233
+ };
234
+ }
package/src/console.ts ADDED
@@ -0,0 +1,182 @@
1
+ /**
2
+ * Console capture: what the host process prints — `console.log`, Nest's logger,
3
+ * pino, anything that reaches stdout or stderr — copied into Midline line by line.
4
+ *
5
+ * The streams are never changed. The original `write` runs first with the
6
+ * caller's own arguments and its return value is handed straight back, so
7
+ * back-pressure, callbacks and the terminal output are exactly what they were.
8
+ */
9
+
10
+ export type ConsoleStream = "stdout" | "stderr";
11
+ export type ConsoleLevel = "info" | "warn" | "error";
12
+
13
+ export interface ConsoleLine {
14
+ stream: ConsoleStream;
15
+ text: string;
16
+ level: ConsoleLevel;
17
+ /** When the line's first chunk was written, not when it was sent. */
18
+ timestamp: string;
19
+ }
20
+
21
+ /** Longest line kept. A log line past this is almost always a dumped payload. */
22
+ export const MAX_LINE_CHARS = 4096;
23
+
24
+ const STREAMS: ConsoleStream[] = ["stdout", "stderr"];
25
+
26
+ /** CSI (colours, cursor moves), OSC (titles, hyperlinks) and the remaining two-byte escapes. */
27
+ const ANSI_ESCAPE = /\x1B\[[0-?]*[ -\/]*[@-~]|\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)|\x1B[@-Z\\-_]/g;
28
+
29
+ // Level words are matched in capitals only: lower-case "error" turns up in plenty of harmless lines.
30
+ const ERROR_MARKERS = [/\b(?:ERROR|FATAL|CRITICAL)\b/, /"level"\s*:\s*"?(?:error|fatal|critical|50|60)\b/i];
31
+ const WARN_MARKERS = [/\bWARN(?:ING)?\b/, /"level"\s*:\s*"?(?:warn|warning|40)\b/i];
32
+
33
+ type WriteFn = (...args: any[]) => boolean;
34
+
35
+ let suppressed = 0;
36
+
37
+ /**
38
+ * Runs `fn` with capture paused. The agent writes its own diagnostics through
39
+ * this, so a warning about delivery can't become an event that needs delivering.
40
+ */
41
+ export function withoutConsoleCapture<T>(fn: () => T): T {
42
+ suppressed += 1;
43
+ try {
44
+ return fn();
45
+ } finally {
46
+ suppressed -= 1;
47
+ }
48
+ }
49
+
50
+ interface Pending {
51
+ text: string;
52
+ at: string;
53
+ /** Already unfinished at the previous flush. */
54
+ stale: boolean;
55
+ }
56
+
57
+ export class ConsoleCapture {
58
+ private readonly originals = new Map<ConsoleStream, WriteFn>();
59
+ private readonly wrappers = new Map<ConsoleStream, WriteFn>();
60
+ private readonly pending: Record<ConsoleStream, Pending> = {
61
+ stdout: { text: "", at: "", stale: false },
62
+ stderr: { text: "", at: "", stale: false },
63
+ };
64
+ private busy = false;
65
+ private stopped = false;
66
+
67
+ constructor(private readonly onLine: (line: ConsoleLine) => void) {}
68
+
69
+ install(): void {
70
+ for (const stream of STREAMS) {
71
+ const target = process[stream];
72
+ const original = target.write as WriteFn;
73
+ const capture = this;
74
+ const wrapper: WriteFn = function (this: unknown, ...args: any[]) {
75
+ const result = original.apply(this, args);
76
+ capture.take(stream, args[0], args[1]);
77
+ return result;
78
+ };
79
+ this.originals.set(stream, original);
80
+ this.wrappers.set(stream, wrapper);
81
+ target.write = wrapper as NodeJS.WriteStream["write"];
82
+ }
83
+ }
84
+
85
+ /** Restores the streams. A wrapper installed on top of ours is left alone — ours just goes quiet underneath it. */
86
+ uninstall(): void {
87
+ this.stopped = true;
88
+ for (const stream of STREAMS) {
89
+ const wrapper = this.wrappers.get(stream);
90
+ if (wrapper && process[stream].write === wrapper) {
91
+ process[stream].write = this.originals.get(stream) as NodeJS.WriteStream["write"];
92
+ }
93
+ }
94
+ this.wrappers.clear();
95
+ this.originals.clear();
96
+ }
97
+
98
+ /**
99
+ * Emits lines still waiting for a newline. Without `force`, only the ones that
100
+ * were already unfinished at the previous call, so a line being written in
101
+ * pieces isn't cut in half by a timer tick.
102
+ */
103
+ flushPending(force = false): void {
104
+ if (this.stopped || this.busy) return;
105
+ this.busy = true;
106
+ try {
107
+ for (const stream of STREAMS) {
108
+ const pending = this.pending[stream];
109
+ if (!pending.text) continue;
110
+ if (force || pending.stale) {
111
+ this.pending[stream] = { text: "", at: "", stale: false };
112
+ this.emit(stream, pending.text, pending.at);
113
+ } else {
114
+ pending.stale = true;
115
+ }
116
+ }
117
+ } catch {
118
+ // Best-effort, like the rest of capture.
119
+ } finally {
120
+ this.busy = false;
121
+ }
122
+ }
123
+
124
+ private take(stream: ConsoleStream, chunk: unknown, encoding: unknown): void {
125
+ // `busy` also stops a loop if whatever handles a line prints something itself.
126
+ if (this.stopped || this.busy || suppressed > 0) return;
127
+ this.busy = true;
128
+ try {
129
+ const text = decode(chunk, encoding);
130
+ if (!text) return;
131
+
132
+ const now = new Date().toISOString();
133
+ const pending = this.pending[stream];
134
+ const lines = (pending.text + text).split("\n");
135
+ let rest = lines.pop() ?? "";
136
+ let at = pending.text ? pending.at : now;
137
+
138
+ for (const line of lines) {
139
+ this.emit(stream, line, at);
140
+ at = now;
141
+ }
142
+ if (rest.length > MAX_LINE_CHARS) {
143
+ this.emit(stream, rest, at);
144
+ rest = "";
145
+ }
146
+ this.pending[stream] = { text: rest, at: rest ? at : "", stale: false };
147
+ } catch {
148
+ // The write itself already happened; a line we couldn't read is just not sent.
149
+ } finally {
150
+ this.busy = false;
151
+ }
152
+ }
153
+
154
+ private emit(stream: ConsoleStream, raw: string, at: string): void {
155
+ let text = raw.replace(ANSI_ESCAPE, "").replace(/\r$/, "");
156
+ // A carriage return redraws the line; what's left after the last one is what the terminal shows.
157
+ text = text.slice(text.lastIndexOf("\r") + 1);
158
+ if (!text.trim()) return;
159
+ if (text.length > MAX_LINE_CHARS) text = text.slice(0, MAX_LINE_CHARS);
160
+ this.onLine({ stream, text, level: levelOf(stream, text), timestamp: at });
161
+ }
162
+ }
163
+
164
+ /** Nest, most JSON loggers and console.warn/error leave enough behind to tell a failure from chatter. */
165
+ export function levelOf(stream: ConsoleStream, text: string): ConsoleLevel {
166
+ const head = text.slice(0, 256);
167
+ if (ERROR_MARKERS.some((pattern) => pattern.test(head))) return "error";
168
+ if (WARN_MARKERS.some((pattern) => pattern.test(head))) return "warn";
169
+ return stream === "stderr" ? "warn" : "info";
170
+ }
171
+
172
+ function decode(chunk: unknown, encoding: unknown): string {
173
+ if (typeof chunk === "string") {
174
+ return typeof encoding === "string" && !/^utf-?8$/i.test(encoding) && Buffer.isEncoding(encoding)
175
+ ? Buffer.from(chunk, encoding).toString("utf8")
176
+ : chunk;
177
+ }
178
+ if (chunk instanceof Uint8Array) {
179
+ return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength).toString("utf8");
180
+ }
181
+ return "";
182
+ }
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
+ }
@@ -1,20 +1,43 @@
1
- import { Request, Response, NextFunction } from "express";
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 function midlineErrorHandler() {
6
- return (err: any, req: Request, res: Response, next: NextFunction) => {
7
- MidlineAgent.addEvent({
8
- type: "error",
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 type { Breadcrumb, MidlineConfig, MidlineEvent } from "./types";
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";