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/README.md +313 -249
- package/dist/agent.d.ts +118 -6
- package/dist/agent.js +746 -99
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +63 -0
- package/dist/config.d.ts +64 -0
- package/dist/config.js +231 -0
- package/dist/console.d.ts +46 -0
- package/dist/console.js +167 -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 +114 -5
- package/package.json +13 -8
- package/src/agent.ts +825 -97
- package/src/cli.ts +69 -0
- package/src/config.ts +234 -0
- package/src/console.ts +182 -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 +151 -31
- package/test/agent.test.js +353 -0
- package/test/console.test.js +182 -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/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
"use strict";
|
|
3
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
4
|
+
const agent_1 = require("./agent");
|
|
5
|
+
const config_1 = require("./config");
|
|
6
|
+
const proxy_1 = require("./proxy");
|
|
7
|
+
const USAGE = `Usage: midline-agent proxy [--target <url>] [--port <n>] [--host <addr>]
|
|
8
|
+
|
|
9
|
+
Forwards traffic to a destination API and reports every exchange to Midline.
|
|
10
|
+
|
|
11
|
+
--target Destination API (TARGET_API_URL, required)
|
|
12
|
+
--port Port to listen on (MIDLINE_PROXY_PORT, default 8080)
|
|
13
|
+
--host Interface to bind (MIDLINE_PROXY_HOST, default 127.0.0.1)
|
|
14
|
+
|
|
15
|
+
Midline server:
|
|
16
|
+
MIDLINE_API_KEY project API key (without it, traffic is forwarded but not reported)
|
|
17
|
+
MIDLINE_ENDPOINT default https://api.usemidline.com
|
|
18
|
+
MIDLINE_CUSTOM_CA extra CA for the Midline server only (PEM or file path)
|
|
19
|
+
TARGET_API_CA extra CA for the destination only (PEM or file path)
|
|
20
|
+
`;
|
|
21
|
+
function flag(args, name) {
|
|
22
|
+
const index = args.indexOf(`--${name}`);
|
|
23
|
+
if (index !== -1)
|
|
24
|
+
return args[index + 1];
|
|
25
|
+
const inline = args.find((arg) => arg.startsWith(`--${name}=`));
|
|
26
|
+
return inline?.slice(name.length + 3);
|
|
27
|
+
}
|
|
28
|
+
async function main() {
|
|
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
|
+
const port = flag(args, "port");
|
|
36
|
+
const target = flag(args, "target") ?? process.env.TARGET_API_URL;
|
|
37
|
+
// Fail on the destination before the agent starts talking about its own config.
|
|
38
|
+
(0, proxy_1.resolveTarget)(target);
|
|
39
|
+
const agent = agent_1.MidlineAgent.init({ serviceName: process.env.MIDLINE_SERVICE_NAME });
|
|
40
|
+
const server = await (0, proxy_1.startMidlineProxy)({
|
|
41
|
+
target,
|
|
42
|
+
port: port ? Number(port) : undefined,
|
|
43
|
+
host: flag(args, "host"),
|
|
44
|
+
agent,
|
|
45
|
+
});
|
|
46
|
+
process.stdout.write(`midline proxy listening on ${(0, proxy_1.proxyAddress)(server)} -> ${server.proxy.target.origin}${server.proxy.target.pathname}` +
|
|
47
|
+
`${agent.active ? "" : " (monitoring off)"}\n`);
|
|
48
|
+
let stopping = false;
|
|
49
|
+
const stop = (signal) => {
|
|
50
|
+
if (stopping)
|
|
51
|
+
return;
|
|
52
|
+
stopping = true;
|
|
53
|
+
process.stdout.write(`midline proxy: ${signal}, draining\n`);
|
|
54
|
+
server.close();
|
|
55
|
+
void agent.shutdown(5000).finally(() => process.exit(0));
|
|
56
|
+
};
|
|
57
|
+
process.on("SIGINT", () => stop("SIGINT"));
|
|
58
|
+
process.on("SIGTERM", () => stop("SIGTERM"));
|
|
59
|
+
}
|
|
60
|
+
main().catch((err) => {
|
|
61
|
+
process.stderr.write(`midline proxy: ${err instanceof config_1.ConfigError ? err.message : err?.stack ?? err}\n`);
|
|
62
|
+
process.exit(1);
|
|
63
|
+
});
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { CaInput, CaptureOptions, MidlineConfig } from "./types";
|
|
2
|
+
export declare const DEFAULT_ENDPOINT = "https://api.usemidline.com";
|
|
3
|
+
export declare const INGEST_PATH = "/api/api-monitor/events";
|
|
4
|
+
/** A setting that cannot work as given. Thrown at construction, never while serving traffic. */
|
|
5
|
+
export declare class ConfigError extends Error {
|
|
6
|
+
constructor(message: string);
|
|
7
|
+
}
|
|
8
|
+
export interface ResolvedCapture {
|
|
9
|
+
headers: boolean;
|
|
10
|
+
query: boolean;
|
|
11
|
+
requestBody: boolean;
|
|
12
|
+
responseBody: boolean;
|
|
13
|
+
maxBodyBytes: number;
|
|
14
|
+
}
|
|
15
|
+
export interface ResolvedConfig {
|
|
16
|
+
apiKey: string;
|
|
17
|
+
serviceName?: string;
|
|
18
|
+
ingestUrl: URL;
|
|
19
|
+
batchUrl: URL;
|
|
20
|
+
/** Full trust store for the Midline endpoint, or undefined for Node's default. */
|
|
21
|
+
ca?: Array<string | Buffer>;
|
|
22
|
+
hasCustomCa: boolean;
|
|
23
|
+
environment?: string;
|
|
24
|
+
host?: string;
|
|
25
|
+
region?: string;
|
|
26
|
+
release?: string;
|
|
27
|
+
redactFields: string[];
|
|
28
|
+
redactHeaders: string[];
|
|
29
|
+
capture: ResolvedCapture;
|
|
30
|
+
captureConsole: boolean;
|
|
31
|
+
onError?: (message: string) => void;
|
|
32
|
+
debug: boolean;
|
|
33
|
+
flushIntervalMs: number;
|
|
34
|
+
timeoutMs: number;
|
|
35
|
+
connectTimeoutMs: number;
|
|
36
|
+
maxBatchSize: number;
|
|
37
|
+
maxQueueSize: number;
|
|
38
|
+
maxEventBytes: number;
|
|
39
|
+
maxRetryDelayMs: number;
|
|
40
|
+
}
|
|
41
|
+
export declare function env(name: string): string | undefined;
|
|
42
|
+
export declare function envFlag(name: string): boolean | undefined;
|
|
43
|
+
export declare function envInt(name: string): number | undefined;
|
|
44
|
+
export declare function isLoopback(hostname: string): boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Turns whatever the user configured into the ingest URL.
|
|
47
|
+
*
|
|
48
|
+
* `https://api.usemidline.com`, `https://api.usemidline.com/api/api-monitor/events`
|
|
49
|
+
* and `https://gateway.internal/midline` all work; the last is treated as a prefix.
|
|
50
|
+
*/
|
|
51
|
+
export declare function resolveIngestUrl(endpoint: string): URL;
|
|
52
|
+
/**
|
|
53
|
+
* Loads extra CAs. Every entry must contain at least one parseable certificate, so a
|
|
54
|
+
* typo in a path fails loudly at startup instead of silently trusting nothing.
|
|
55
|
+
*/
|
|
56
|
+
export declare function loadCa(input: CaInput | undefined, label: string): Buffer[] | undefined;
|
|
57
|
+
/**
|
|
58
|
+
* Node's `ca` option *replaces* the default trust store. Passing only a private CA
|
|
59
|
+
* would make every public certificate untrusted, so extra CAs are appended to the
|
|
60
|
+
* defaults (including NODE_EXTRA_CA_CERTS where the runtime exposes them).
|
|
61
|
+
*/
|
|
62
|
+
export declare function trustStore(extra: Buffer[] | undefined): Array<string | Buffer> | undefined;
|
|
63
|
+
export declare function resolveCapture(capture: CaptureOptions | undefined): ResolvedCapture;
|
|
64
|
+
export declare function resolveConfig(config: MidlineConfig): ResolvedConfig;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.ConfigError = exports.INGEST_PATH = exports.DEFAULT_ENDPOINT = void 0;
|
|
37
|
+
exports.env = env;
|
|
38
|
+
exports.envFlag = envFlag;
|
|
39
|
+
exports.envInt = envInt;
|
|
40
|
+
exports.isLoopback = isLoopback;
|
|
41
|
+
exports.resolveIngestUrl = resolveIngestUrl;
|
|
42
|
+
exports.loadCa = loadCa;
|
|
43
|
+
exports.trustStore = trustStore;
|
|
44
|
+
exports.resolveCapture = resolveCapture;
|
|
45
|
+
exports.resolveConfig = resolveConfig;
|
|
46
|
+
const fs_1 = require("fs");
|
|
47
|
+
const crypto_1 = require("crypto");
|
|
48
|
+
const tls = __importStar(require("tls"));
|
|
49
|
+
exports.DEFAULT_ENDPOINT = "https://api.usemidline.com";
|
|
50
|
+
exports.INGEST_PATH = "/api/api-monitor/events";
|
|
51
|
+
/** A setting that cannot work as given. Thrown at construction, never while serving traffic. */
|
|
52
|
+
class ConfigError extends Error {
|
|
53
|
+
constructor(message) {
|
|
54
|
+
super(message);
|
|
55
|
+
this.name = "ConfigError";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
exports.ConfigError = ConfigError;
|
|
59
|
+
function env(name) {
|
|
60
|
+
if (typeof process === "undefined" || !process.env) {
|
|
61
|
+
return undefined;
|
|
62
|
+
}
|
|
63
|
+
const value = process.env[name];
|
|
64
|
+
return value === undefined || value.trim() === "" ? undefined : value.trim();
|
|
65
|
+
}
|
|
66
|
+
function envFlag(name) {
|
|
67
|
+
const value = env(name)?.toLowerCase();
|
|
68
|
+
if (value === undefined)
|
|
69
|
+
return undefined;
|
|
70
|
+
if (["1", "true", "yes", "on"].includes(value))
|
|
71
|
+
return true;
|
|
72
|
+
if (["0", "false", "no", "off"].includes(value))
|
|
73
|
+
return false;
|
|
74
|
+
return undefined;
|
|
75
|
+
}
|
|
76
|
+
function envInt(name) {
|
|
77
|
+
const value = env(name);
|
|
78
|
+
if (value === undefined)
|
|
79
|
+
return undefined;
|
|
80
|
+
const parsed = Number(value);
|
|
81
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
82
|
+
}
|
|
83
|
+
function isLoopback(hostname) {
|
|
84
|
+
const host = hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
|
85
|
+
return (host === "localhost" ||
|
|
86
|
+
host.endsWith(".localhost") ||
|
|
87
|
+
host === "::1" ||
|
|
88
|
+
/^127(\.\d{1,3}){3}$/.test(host));
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Turns whatever the user configured into the ingest URL.
|
|
92
|
+
*
|
|
93
|
+
* `https://api.usemidline.com`, `https://api.usemidline.com/api/api-monitor/events`
|
|
94
|
+
* and `https://gateway.internal/midline` all work; the last is treated as a prefix.
|
|
95
|
+
*/
|
|
96
|
+
function resolveIngestUrl(endpoint) {
|
|
97
|
+
let url;
|
|
98
|
+
try {
|
|
99
|
+
url = new URL(endpoint);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
throw new ConfigError(`endpoint "${endpoint}" is not a valid URL`);
|
|
103
|
+
}
|
|
104
|
+
if (url.protocol !== "https:" && url.protocol !== "http:") {
|
|
105
|
+
throw new ConfigError(`endpoint must use https:// (got ${url.protocol}//)`);
|
|
106
|
+
}
|
|
107
|
+
if (url.protocol === "http:" && !isLoopback(url.hostname)) {
|
|
108
|
+
throw new ConfigError(`refusing to send the API key in cleartext to ${url.origin} — use https://. ` +
|
|
109
|
+
"Plain http:// is only accepted for localhost.");
|
|
110
|
+
}
|
|
111
|
+
if (url.username || url.password) {
|
|
112
|
+
throw new ConfigError("endpoint must not contain credentials; pass the key as apiKey");
|
|
113
|
+
}
|
|
114
|
+
url.search = "";
|
|
115
|
+
url.hash = "";
|
|
116
|
+
const path = url.pathname.replace(/\/+$/, "");
|
|
117
|
+
if (path.endsWith(`${exports.INGEST_PATH}/batch`)) {
|
|
118
|
+
url.pathname = path.slice(0, -"/batch".length);
|
|
119
|
+
}
|
|
120
|
+
else if (path.endsWith(exports.INGEST_PATH)) {
|
|
121
|
+
url.pathname = path;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
url.pathname = `${path}${exports.INGEST_PATH}`;
|
|
125
|
+
}
|
|
126
|
+
return url;
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Loads extra CAs. Every entry must contain at least one parseable certificate, so a
|
|
130
|
+
* typo in a path fails loudly at startup instead of silently trusting nothing.
|
|
131
|
+
*/
|
|
132
|
+
function loadCa(input, label) {
|
|
133
|
+
if (input === undefined || input === null || (Array.isArray(input) && input.length === 0)) {
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
const entries = Array.isArray(input) ? input : [input];
|
|
137
|
+
return entries.map((entry, index) => {
|
|
138
|
+
let pem;
|
|
139
|
+
if (Buffer.isBuffer(entry)) {
|
|
140
|
+
pem = entry;
|
|
141
|
+
}
|
|
142
|
+
else if (typeof entry === "string" && entry.includes("-----BEGIN")) {
|
|
143
|
+
pem = Buffer.from(entry);
|
|
144
|
+
}
|
|
145
|
+
else if (typeof entry === "string") {
|
|
146
|
+
try {
|
|
147
|
+
pem = (0, fs_1.readFileSync)(entry);
|
|
148
|
+
}
|
|
149
|
+
catch (err) {
|
|
150
|
+
throw new ConfigError(`${label}: cannot read CA file "${entry}" (${err?.code ?? err?.message})`);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
else {
|
|
154
|
+
throw new ConfigError(`${label}: entry ${index} must be a PEM string, a Buffer or a file path`);
|
|
155
|
+
}
|
|
156
|
+
if (!pem.toString("utf8").includes("-----BEGIN CERTIFICATE-----")) {
|
|
157
|
+
throw new ConfigError(`${label}: entry ${index} does not contain a PEM certificate`);
|
|
158
|
+
}
|
|
159
|
+
try {
|
|
160
|
+
new crypto_1.X509Certificate(pem);
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
throw new ConfigError(`${label}: entry ${index} is not a parseable certificate`);
|
|
164
|
+
}
|
|
165
|
+
return pem;
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* Node's `ca` option *replaces* the default trust store. Passing only a private CA
|
|
170
|
+
* would make every public certificate untrusted, so extra CAs are appended to the
|
|
171
|
+
* defaults (including NODE_EXTRA_CA_CERTS where the runtime exposes them).
|
|
172
|
+
*/
|
|
173
|
+
function trustStore(extra) {
|
|
174
|
+
if (!extra || extra.length === 0) {
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
177
|
+
const getCACertificates = tls.getCACertificates;
|
|
178
|
+
const defaults = typeof getCACertificates === "function"
|
|
179
|
+
? getCACertificates("default")
|
|
180
|
+
: [...tls.rootCertificates];
|
|
181
|
+
return [...defaults, ...extra];
|
|
182
|
+
}
|
|
183
|
+
function positiveInt(value, fallback, min, max) {
|
|
184
|
+
if (value === undefined || !Number.isFinite(value)) {
|
|
185
|
+
return fallback;
|
|
186
|
+
}
|
|
187
|
+
return Math.min(Math.max(Math.floor(value), min), max);
|
|
188
|
+
}
|
|
189
|
+
function resolveCapture(capture) {
|
|
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
|
+
function resolveConfig(config) {
|
|
199
|
+
const apiKey = (config.apiKey ?? env("MIDLINE_API_KEY") ?? "").trim();
|
|
200
|
+
const ingestUrl = resolveIngestUrl(config.endpoint || env("MIDLINE_ENDPOINT") || exports.DEFAULT_ENDPOINT);
|
|
201
|
+
const extraCa = loadCa(config.ca ?? env("MIDLINE_CUSTOM_CA"), "ca / MIDLINE_CUSTOM_CA");
|
|
202
|
+
if (extraCa && ingestUrl.protocol !== "https:") {
|
|
203
|
+
throw new ConfigError("ca / MIDLINE_CUSTOM_CA is set but the endpoint is not https://");
|
|
204
|
+
}
|
|
205
|
+
return {
|
|
206
|
+
apiKey,
|
|
207
|
+
serviceName: config.serviceName ?? env("MIDLINE_SERVICE_NAME"),
|
|
208
|
+
ingestUrl,
|
|
209
|
+
batchUrl: new URL(`${ingestUrl.pathname}/batch`, ingestUrl),
|
|
210
|
+
ca: trustStore(extraCa),
|
|
211
|
+
hasCustomCa: Boolean(extraCa),
|
|
212
|
+
environment: config.environment ?? env("MIDLINE_ENVIRONMENT"),
|
|
213
|
+
host: config.host,
|
|
214
|
+
region: config.region,
|
|
215
|
+
release: config.release ?? env("MIDLINE_RELEASE"),
|
|
216
|
+
redactFields: [...(config.redactFields ?? []), ...(config.maskFields ?? [])],
|
|
217
|
+
redactHeaders: config.redactHeaders ?? [],
|
|
218
|
+
capture: resolveCapture(config.capture),
|
|
219
|
+
captureConsole: config.captureConsole ?? envFlag("MIDLINE_CAPTURE_CONSOLE") ?? false,
|
|
220
|
+
onError: config.onError,
|
|
221
|
+
debug: config.debug ?? envFlag("MIDLINE_DEBUG") ?? false,
|
|
222
|
+
flushIntervalMs: positiveInt(config.flushIntervalMs, 1500, 50, 60000),
|
|
223
|
+
timeoutMs: positiveInt(config.timeoutMs, 10000, 100, 120000),
|
|
224
|
+
connectTimeoutMs: positiveInt(config.connectTimeoutMs, 5000, 50, 60000),
|
|
225
|
+
maxBatchSize: positiveInt(config.maxBatchSize, 100, 1, 500),
|
|
226
|
+
maxQueueSize: positiveInt(config.maxQueueSize, 1000, 1, 100000),
|
|
227
|
+
// The server caps a payload at 64 KiB; a larger event could never be accepted.
|
|
228
|
+
maxEventBytes: positiveInt(config.maxEventBytes, 64 * 1024, 1024, 64 * 1024),
|
|
229
|
+
maxRetryDelayMs: positiveInt(config.maxRetryDelayMs, 5 * 60000, 1000, 60 * 60000),
|
|
230
|
+
};
|
|
231
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
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
|
+
export type ConsoleStream = "stdout" | "stderr";
|
|
10
|
+
export type ConsoleLevel = "info" | "warn" | "error";
|
|
11
|
+
export interface ConsoleLine {
|
|
12
|
+
stream: ConsoleStream;
|
|
13
|
+
text: string;
|
|
14
|
+
level: ConsoleLevel;
|
|
15
|
+
/** When the line's first chunk was written, not when it was sent. */
|
|
16
|
+
timestamp: string;
|
|
17
|
+
}
|
|
18
|
+
/** Longest line kept. A log line past this is almost always a dumped payload. */
|
|
19
|
+
export declare const MAX_LINE_CHARS = 4096;
|
|
20
|
+
/**
|
|
21
|
+
* Runs `fn` with capture paused. The agent writes its own diagnostics through
|
|
22
|
+
* this, so a warning about delivery can't become an event that needs delivering.
|
|
23
|
+
*/
|
|
24
|
+
export declare function withoutConsoleCapture<T>(fn: () => T): T;
|
|
25
|
+
export declare class ConsoleCapture {
|
|
26
|
+
private readonly onLine;
|
|
27
|
+
private readonly originals;
|
|
28
|
+
private readonly wrappers;
|
|
29
|
+
private readonly pending;
|
|
30
|
+
private busy;
|
|
31
|
+
private stopped;
|
|
32
|
+
constructor(onLine: (line: ConsoleLine) => void);
|
|
33
|
+
install(): void;
|
|
34
|
+
/** Restores the streams. A wrapper installed on top of ours is left alone — ours just goes quiet underneath it. */
|
|
35
|
+
uninstall(): void;
|
|
36
|
+
/**
|
|
37
|
+
* Emits lines still waiting for a newline. Without `force`, only the ones that
|
|
38
|
+
* were already unfinished at the previous call, so a line being written in
|
|
39
|
+
* pieces isn't cut in half by a timer tick.
|
|
40
|
+
*/
|
|
41
|
+
flushPending(force?: boolean): void;
|
|
42
|
+
private take;
|
|
43
|
+
private emit;
|
|
44
|
+
}
|
|
45
|
+
/** Nest, most JSON loggers and console.warn/error leave enough behind to tell a failure from chatter. */
|
|
46
|
+
export declare function levelOf(stream: ConsoleStream, text: string): ConsoleLevel;
|
package/dist/console.js
ADDED
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Console capture: what the host process prints — `console.log`, Nest's logger,
|
|
4
|
+
* pino, anything that reaches stdout or stderr — copied into Midline line by line.
|
|
5
|
+
*
|
|
6
|
+
* The streams are never changed. The original `write` runs first with the
|
|
7
|
+
* caller's own arguments and its return value is handed straight back, so
|
|
8
|
+
* back-pressure, callbacks and the terminal output are exactly what they were.
|
|
9
|
+
*/
|
|
10
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
11
|
+
exports.ConsoleCapture = exports.MAX_LINE_CHARS = void 0;
|
|
12
|
+
exports.withoutConsoleCapture = withoutConsoleCapture;
|
|
13
|
+
exports.levelOf = levelOf;
|
|
14
|
+
/** Longest line kept. A log line past this is almost always a dumped payload. */
|
|
15
|
+
exports.MAX_LINE_CHARS = 4096;
|
|
16
|
+
const STREAMS = ["stdout", "stderr"];
|
|
17
|
+
/** CSI (colours, cursor moves), OSC (titles, hyperlinks) and the remaining two-byte escapes. */
|
|
18
|
+
const ANSI_ESCAPE = /\x1B\[[0-?]*[ -\/]*[@-~]|\x1B\][^\x07\x1B]*(?:\x07|\x1B\\)|\x1B[@-Z\\-_]/g;
|
|
19
|
+
// Level words are matched in capitals only: lower-case "error" turns up in plenty of harmless lines.
|
|
20
|
+
const ERROR_MARKERS = [/\b(?:ERROR|FATAL|CRITICAL)\b/, /"level"\s*:\s*"?(?:error|fatal|critical|50|60)\b/i];
|
|
21
|
+
const WARN_MARKERS = [/\bWARN(?:ING)?\b/, /"level"\s*:\s*"?(?:warn|warning|40)\b/i];
|
|
22
|
+
let suppressed = 0;
|
|
23
|
+
/**
|
|
24
|
+
* Runs `fn` with capture paused. The agent writes its own diagnostics through
|
|
25
|
+
* this, so a warning about delivery can't become an event that needs delivering.
|
|
26
|
+
*/
|
|
27
|
+
function withoutConsoleCapture(fn) {
|
|
28
|
+
suppressed += 1;
|
|
29
|
+
try {
|
|
30
|
+
return fn();
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
suppressed -= 1;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
class ConsoleCapture {
|
|
37
|
+
constructor(onLine) {
|
|
38
|
+
this.onLine = onLine;
|
|
39
|
+
this.originals = new Map();
|
|
40
|
+
this.wrappers = new Map();
|
|
41
|
+
this.pending = {
|
|
42
|
+
stdout: { text: "", at: "", stale: false },
|
|
43
|
+
stderr: { text: "", at: "", stale: false },
|
|
44
|
+
};
|
|
45
|
+
this.busy = false;
|
|
46
|
+
this.stopped = false;
|
|
47
|
+
}
|
|
48
|
+
install() {
|
|
49
|
+
for (const stream of STREAMS) {
|
|
50
|
+
const target = process[stream];
|
|
51
|
+
const original = target.write;
|
|
52
|
+
const capture = this;
|
|
53
|
+
const wrapper = function (...args) {
|
|
54
|
+
const result = original.apply(this, args);
|
|
55
|
+
capture.take(stream, args[0], args[1]);
|
|
56
|
+
return result;
|
|
57
|
+
};
|
|
58
|
+
this.originals.set(stream, original);
|
|
59
|
+
this.wrappers.set(stream, wrapper);
|
|
60
|
+
target.write = wrapper;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** Restores the streams. A wrapper installed on top of ours is left alone — ours just goes quiet underneath it. */
|
|
64
|
+
uninstall() {
|
|
65
|
+
this.stopped = true;
|
|
66
|
+
for (const stream of STREAMS) {
|
|
67
|
+
const wrapper = this.wrappers.get(stream);
|
|
68
|
+
if (wrapper && process[stream].write === wrapper) {
|
|
69
|
+
process[stream].write = this.originals.get(stream);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
this.wrappers.clear();
|
|
73
|
+
this.originals.clear();
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Emits lines still waiting for a newline. Without `force`, only the ones that
|
|
77
|
+
* were already unfinished at the previous call, so a line being written in
|
|
78
|
+
* pieces isn't cut in half by a timer tick.
|
|
79
|
+
*/
|
|
80
|
+
flushPending(force = false) {
|
|
81
|
+
if (this.stopped || this.busy)
|
|
82
|
+
return;
|
|
83
|
+
this.busy = true;
|
|
84
|
+
try {
|
|
85
|
+
for (const stream of STREAMS) {
|
|
86
|
+
const pending = this.pending[stream];
|
|
87
|
+
if (!pending.text)
|
|
88
|
+
continue;
|
|
89
|
+
if (force || pending.stale) {
|
|
90
|
+
this.pending[stream] = { text: "", at: "", stale: false };
|
|
91
|
+
this.emit(stream, pending.text, pending.at);
|
|
92
|
+
}
|
|
93
|
+
else {
|
|
94
|
+
pending.stale = true;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// Best-effort, like the rest of capture.
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
this.busy = false;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
take(stream, chunk, encoding) {
|
|
106
|
+
// `busy` also stops a loop if whatever handles a line prints something itself.
|
|
107
|
+
if (this.stopped || this.busy || suppressed > 0)
|
|
108
|
+
return;
|
|
109
|
+
this.busy = true;
|
|
110
|
+
try {
|
|
111
|
+
const text = decode(chunk, encoding);
|
|
112
|
+
if (!text)
|
|
113
|
+
return;
|
|
114
|
+
const now = new Date().toISOString();
|
|
115
|
+
const pending = this.pending[stream];
|
|
116
|
+
const lines = (pending.text + text).split("\n");
|
|
117
|
+
let rest = lines.pop() ?? "";
|
|
118
|
+
let at = pending.text ? pending.at : now;
|
|
119
|
+
for (const line of lines) {
|
|
120
|
+
this.emit(stream, line, at);
|
|
121
|
+
at = now;
|
|
122
|
+
}
|
|
123
|
+
if (rest.length > exports.MAX_LINE_CHARS) {
|
|
124
|
+
this.emit(stream, rest, at);
|
|
125
|
+
rest = "";
|
|
126
|
+
}
|
|
127
|
+
this.pending[stream] = { text: rest, at: rest ? at : "", stale: false };
|
|
128
|
+
}
|
|
129
|
+
catch {
|
|
130
|
+
// The write itself already happened; a line we couldn't read is just not sent.
|
|
131
|
+
}
|
|
132
|
+
finally {
|
|
133
|
+
this.busy = false;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
emit(stream, raw, at) {
|
|
137
|
+
let text = raw.replace(ANSI_ESCAPE, "").replace(/\r$/, "");
|
|
138
|
+
// A carriage return redraws the line; what's left after the last one is what the terminal shows.
|
|
139
|
+
text = text.slice(text.lastIndexOf("\r") + 1);
|
|
140
|
+
if (!text.trim())
|
|
141
|
+
return;
|
|
142
|
+
if (text.length > exports.MAX_LINE_CHARS)
|
|
143
|
+
text = text.slice(0, exports.MAX_LINE_CHARS);
|
|
144
|
+
this.onLine({ stream, text, level: levelOf(stream, text), timestamp: at });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
exports.ConsoleCapture = ConsoleCapture;
|
|
148
|
+
/** Nest, most JSON loggers and console.warn/error leave enough behind to tell a failure from chatter. */
|
|
149
|
+
function levelOf(stream, text) {
|
|
150
|
+
const head = text.slice(0, 256);
|
|
151
|
+
if (ERROR_MARKERS.some((pattern) => pattern.test(head)))
|
|
152
|
+
return "error";
|
|
153
|
+
if (WARN_MARKERS.some((pattern) => pattern.test(head)))
|
|
154
|
+
return "warn";
|
|
155
|
+
return stream === "stderr" ? "warn" : "info";
|
|
156
|
+
}
|
|
157
|
+
function decode(chunk, encoding) {
|
|
158
|
+
if (typeof chunk === "string") {
|
|
159
|
+
return typeof encoding === "string" && !/^utf-?8$/i.test(encoding) && Buffer.isEncoding(encoding)
|
|
160
|
+
? Buffer.from(chunk, encoding).toString("utf8")
|
|
161
|
+
: chunk;
|
|
162
|
+
}
|
|
163
|
+
if (chunk instanceof Uint8Array) {
|
|
164
|
+
return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength).toString("utf8");
|
|
165
|
+
}
|
|
166
|
+
return "";
|
|
167
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { IncomingHttpHeaders } from "http";
|
|
2
|
+
export interface RequestContext {
|
|
3
|
+
/** This hop's id. Taken from `x-request-id` when the caller sent a sane one. */
|
|
4
|
+
requestId: string;
|
|
5
|
+
/** Shared across every service a request touches. Falls back to the request id. */
|
|
6
|
+
correlationId: string;
|
|
7
|
+
traceId?: string;
|
|
8
|
+
spanId?: string;
|
|
9
|
+
}
|
|
10
|
+
export declare function createRequestContext(headers: IncomingHttpHeaders): RequestContext;
|
|
11
|
+
export declare function setRequestContext(req: object, context: RequestContext): void;
|
|
12
|
+
/** The ids Midline recorded for this request — useful for stamping your own logs. */
|
|
13
|
+
export declare function getRequestContext(req: object): RequestContext | undefined;
|
package/dist/context.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.createRequestContext = createRequestContext;
|
|
4
|
+
exports.setRequestContext = setRequestContext;
|
|
5
|
+
exports.getRequestContext = getRequestContext;
|
|
6
|
+
const crypto_1 = require("crypto");
|
|
7
|
+
const contexts = new WeakMap();
|
|
8
|
+
/** Ids come from the network, so only short, printable ones are reused. */
|
|
9
|
+
function sanitizeId(value) {
|
|
10
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
11
|
+
if (typeof raw !== "string")
|
|
12
|
+
return undefined;
|
|
13
|
+
const trimmed = raw.trim();
|
|
14
|
+
return /^[A-Za-z0-9._:\/+=-]{1,128}$/.test(trimmed) ? trimmed : undefined;
|
|
15
|
+
}
|
|
16
|
+
/** W3C trace context: `00-<32 hex trace id>-<16 hex parent id>-<2 hex flags>`. */
|
|
17
|
+
function parseTraceparent(value) {
|
|
18
|
+
const raw = Array.isArray(value) ? value[0] : value;
|
|
19
|
+
const match = typeof raw === "string"
|
|
20
|
+
? /^[\da-f]{2}-([\da-f]{32})-([\da-f]{16})-[\da-f]{2}$/.exec(raw.trim().toLowerCase())
|
|
21
|
+
: null;
|
|
22
|
+
if (!match || /^0+$/.test(match[1]) || /^0+$/.test(match[2])) {
|
|
23
|
+
return undefined;
|
|
24
|
+
}
|
|
25
|
+
return { traceId: match[1], spanId: match[2] };
|
|
26
|
+
}
|
|
27
|
+
function createRequestContext(headers) {
|
|
28
|
+
const requestId = sanitizeId(headers["x-request-id"]) ?? (0, crypto_1.randomUUID)();
|
|
29
|
+
const correlationId = sanitizeId(headers["x-correlation-id"]) ?? requestId;
|
|
30
|
+
return { requestId, correlationId, ...parseTraceparent(headers["traceparent"]) };
|
|
31
|
+
}
|
|
32
|
+
function setRequestContext(req, context) {
|
|
33
|
+
contexts.set(req, context);
|
|
34
|
+
}
|
|
35
|
+
/** The ids Midline recorded for this request — useful for stamping your own logs. */
|
|
36
|
+
function getRequestContext(req) {
|
|
37
|
+
return contexts.get(req);
|
|
38
|
+
}
|
package/dist/errorHandler.d.ts
CHANGED
|
@@ -1,2 +1,11 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
1
|
+
import type { IncomingMessage, ServerResponse } from "http";
|
|
2
|
+
import { MidlineAgent } from "./agent";
|
|
3
|
+
export interface MidlineErrorHandlerOptions {
|
|
4
|
+
/** Defaults to the agent created by `MidlineAgent.init()`. */
|
|
5
|
+
agent?: MidlineAgent;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* Express error middleware. Records the error, then passes it on unchanged — it
|
|
9
|
+
* never sends a response itself. Register it after your routes.
|
|
10
|
+
*/
|
|
11
|
+
export declare function midlineErrorHandler(options?: MidlineErrorHandlerOptions): (err: any, req: IncomingMessage, _res: ServerResponse, next: (err?: unknown) => void) => void;
|