midline-agent 0.5.0 → 0.7.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/CHANGELOG.md +11 -0
- package/README.md +57 -0
- package/dist/agent.js +1 -1
- package/dist/browser/client.js +4 -0
- package/dist/browser/types.d.ts +7 -0
- package/dist/browser/version.d.ts +1 -1
- package/dist/browser/version.js +1 -1
- package/dist/cli.js +36 -1
- package/dist/client-ip.d.ts +20 -0
- package/dist/client-ip.js +29 -0
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -0
- package/dist/esm/browser/client.js +4 -0
- package/dist/esm/browser/version.js +1 -1
- package/dist/middleware.d.ts +9 -0
- package/dist/middleware.js +2 -1
- package/dist/sourcemaps.d.ts +26 -0
- package/dist/sourcemaps.js +118 -0
- package/dist/types.d.ts +6 -0
- package/package.json +25 -8
- package/src/agent.ts +1 -1
- package/src/browser/client.ts +4 -0
- package/src/browser/types.ts +7 -0
- package/src/browser/version.ts +1 -1
- package/src/cli.ts +35 -1
- package/src/client-ip.ts +38 -0
- package/src/config.ts +2 -0
- package/src/middleware.ts +10 -1
- package/src/sourcemaps.ts +99 -0
- package/src/types.ts +6 -0
- package/test/browser.test.js +13 -0
- package/test/middleware.test.js +72 -0
- package/test/sourcemaps.test.js +71 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to `midline-agent`, compiled from git history. Dates reflect commit dates, newest first.
|
|
4
4
|
|
|
5
|
+
## 2026-09-21
|
|
6
|
+
- feat: `midlineMiddleware({ trustProxy })` records the end user's IP from `X-Forwarded-For` when the server
|
|
7
|
+
sits behind a load balancer or CDN, instead of the proxy's address
|
|
8
|
+
- feat: `captureIp: false` (env `MIDLINE_CAPTURE_IP=false`) stops the Node agent recording IP addresses;
|
|
9
|
+
the browser SDK's `captureIp: false` asks the Midline server not to keep the visitor's (0.7.0)
|
|
10
|
+
|
|
11
|
+
## 2026-09-20
|
|
12
|
+
- feat: `midline-agent sourcemaps upload <dir> --release <name>` uploads a build's source maps to Midline
|
|
13
|
+
so stack traces show original code. Authenticates with a project server API key; browser keys are refused
|
|
14
|
+
(0.6.0)
|
|
15
|
+
|
|
5
16
|
## 2026-09-17
|
|
6
17
|
- feat: `midline-agent/browser` captures pageviews — on load and on every SPA route change — as the
|
|
7
18
|
basis for visitor counting and funnel views in the Midline dashboard (0.5.0)
|
package/README.md
CHANGED
|
@@ -78,6 +78,21 @@ app.use(midlineErrorHandler()); // after your routes; passes the error on untouc
|
|
|
78
78
|
app.listen(3000);
|
|
79
79
|
```
|
|
80
80
|
|
|
81
|
+
#### Behind a load balancer or CDN
|
|
82
|
+
|
|
83
|
+
By default the IP recorded for each request is Express's `req.ip`. Behind a load balancer that is the
|
|
84
|
+
load balancer's address, not your user's. Tell the middleware how many proxies sit in front of your server
|
|
85
|
+
and it reads the real address from `X-Forwarded-For`:
|
|
86
|
+
|
|
87
|
+
```ts
|
|
88
|
+
app.use(midlineMiddleware({ trustProxy: 1 })); // one load balancer or CDN in front
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
It counts that many hops in from the end of the chain, so an address a client puts in the header
|
|
92
|
+
itself is ignored. Leave it unset if clients reach your server directly, or if you already set
|
|
93
|
+
`app.set("trust proxy", …)` yourself. `trustProxy: true` believes the whole header, which is only safe when
|
|
94
|
+
nothing can reach the server except through your proxies.
|
|
95
|
+
|
|
81
96
|
### NestJS
|
|
82
97
|
|
|
83
98
|
```ts
|
|
@@ -249,6 +264,7 @@ MidlineAgent.init({
|
|
|
249
264
|
redactFields?: string[], // added to the built-in list (maskFields still works)
|
|
250
265
|
redactHeaders?: string[],
|
|
251
266
|
captureConsole?: boolean, // MIDLINE_CAPTURE_CONSOLE — also send what the process prints (off by default)
|
|
267
|
+
captureIp?: boolean, // MIDLINE_CAPTURE_IP — record the caller's IP (on by default; false leaves it out)
|
|
252
268
|
|
|
253
269
|
// Delivery
|
|
254
270
|
flushIntervalMs?: number, // default 1500
|
|
@@ -270,6 +286,7 @@ MidlineAgent.init({
|
|
|
270
286
|
MIDLINE_API_KEY=...
|
|
271
287
|
MIDLINE_ENDPOINT=https://api.usemidline.com
|
|
272
288
|
# MIDLINE_CAPTURE_CONSOLE=true
|
|
289
|
+
# MIDLINE_CAPTURE_IP=false
|
|
273
290
|
|
|
274
291
|
# proxy mode
|
|
275
292
|
TARGET_API_URL=http://localhost:4000
|
|
@@ -309,6 +326,45 @@ const app = await NestFactory.create(AppModule); // startup lines are captured f
|
|
|
309
326
|
|
|
310
327
|
---
|
|
311
328
|
|
|
329
|
+
## IP addresses
|
|
330
|
+
|
|
331
|
+
Request events carry the address of whoever made the request. It shows in the dashboard as the **IP address**
|
|
332
|
+
column on Logs and on the Visitors page's **Recent visitors** table. Where it comes from depends on how you
|
|
333
|
+
connect:
|
|
334
|
+
|
|
335
|
+
| You use | Address recorded |
|
|
336
|
+
| --- | --- |
|
|
337
|
+
| Express or NestJS middleware | Express's `req.ip`. With `trustProxy`, the client read from `X-Forwarded-For`. |
|
|
338
|
+
| Plain Node `http` middleware | The socket's peer. With `trustProxy`, the client read from `X-Forwarded-For`. |
|
|
339
|
+
| Proxy mode | The socket's peer. With `trustForwardedHeaders`, the first `X-Forwarded-For` entry. |
|
|
340
|
+
| Browser SDK | The address the Midline server saw the browser connect from. Nothing to configure, and any IP the page sends is ignored. |
|
|
341
|
+
|
|
342
|
+
- **Behind a load balancer or CDN**, set `trustProxy` (see [Express](#behind-a-load-balancer-or-cdn)) or every request shows the
|
|
343
|
+
proxy's address instead of your user's.
|
|
344
|
+
- **On your own machine** every caller is `::1` or `127.0.0.1`: the address a computer uses for itself. Real addresses
|
|
345
|
+
appear once real traffic reaches the service.
|
|
346
|
+
- **A self-hosted Midline server** that sits behind a proxy needs `TRUST_PROXY=<number of proxies>` in its own
|
|
347
|
+
environment, so browser events record the visitor rather than the proxy. Left unset, `X-Forwarded-For` is ignored,
|
|
348
|
+
which stops a client from choosing the address that is recorded.
|
|
349
|
+
|
|
350
|
+
### Not recording IP addresses
|
|
351
|
+
|
|
352
|
+
An IP address can count as personal data. To keep it out entirely:
|
|
353
|
+
|
|
354
|
+
```ts
|
|
355
|
+
MidlineAgent.init({ apiKey: process.env.MIDLINE_API_KEY, captureIp: false }); // or MIDLINE_CAPTURE_IP=false
|
|
356
|
+
```
|
|
357
|
+
|
|
358
|
+
```ts
|
|
359
|
+
Midline.init({ apiKey: "pk_…", captureIp: false }); // browser SDK
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
The Node agent drops the address in your process, so it is never sent. The browser SDK can only ask: the address is read by
|
|
363
|
+
the Midline server, which then doesn't store it. A server that predates 0.7.0 ignores the request, so update a
|
|
364
|
+
self-hosted server before relying on it. Events without an address show "—" in the dashboard.
|
|
365
|
+
|
|
366
|
+
---
|
|
367
|
+
|
|
312
368
|
## Sensitive data
|
|
313
369
|
|
|
314
370
|
Redaction happens **in your process, before an event is queued**. What's masked never reaches a
|
|
@@ -426,6 +482,7 @@ the browser will block the call's preflight.
|
|
|
426
482
|
| `captureRequests` | `"failed"` | `"failed"` (4xx, 5xx, network errors), `"all"`, or `false`. Every call is a breadcrumb either way. |
|
|
427
483
|
| `captureWebVitals` | `true` | Reported once, when the page is first hidden. |
|
|
428
484
|
| `capturePageviews` | `true` | A `pageview` event on load and on every SPA route change (`pushState`/`replaceState`/`popstate`), tagged with a per-tab `sessionId` — what the dashboard's visitor and funnel views are built from. |
|
|
485
|
+
| `captureIp` | `true` | The visitor's IP is read by the Midline server from the connection, not by the browser. `false` asks the server not to keep it. Needs a server that supports it (an older one ignores the request). |
|
|
429
486
|
| `captureConsole` | `false` | `true` for `error` and `warn`, or a list of levels. Lines appear on the Terminal page. Opt-in because wrapped console calls show the SDK as their source in devtools. |
|
|
430
487
|
| `tracePropagationTargets` | same origin | Strings match as URL prefixes (or path prefixes starting with `/`); RegExps match the full URL. |
|
|
431
488
|
| `ignoreErrors`, `ignoreUrls` | `[]` | Strings match as substrings. |
|
package/dist/agent.js
CHANGED
|
@@ -363,7 +363,7 @@ class MidlineAgent {
|
|
|
363
363
|
timestamp: validTimestamp(event.timestamp),
|
|
364
364
|
// Clamped to the server's validation limits: one over-long field would
|
|
365
365
|
// otherwise get every event rejected.
|
|
366
|
-
ip: clamp(event.ip, 64),
|
|
366
|
+
ip: config.captureIp ? clamp(event.ip, 64) : undefined,
|
|
367
367
|
userAgent: clamp(event.userAgent, 512),
|
|
368
368
|
service: clamp(config.serviceName, 128),
|
|
369
369
|
environment: clamp(config.environment, 128),
|
package/dist/browser/client.js
CHANGED
|
@@ -82,6 +82,7 @@ function resolve(config) {
|
|
|
82
82
|
consoleLevels: levels.filter((level) => level in CONSOLE_SEVERITY),
|
|
83
83
|
captureWebVitals: config.captureWebVitals !== false,
|
|
84
84
|
capturePageviews: config.capturePageviews !== false,
|
|
85
|
+
captureIp: config.captureIp !== false,
|
|
85
86
|
tracePropagationTargets: config.tracePropagationTargets,
|
|
86
87
|
ignoreErrors: config.ignoreErrors ?? [],
|
|
87
88
|
ignoreUrls: config.ignoreUrls ?? [],
|
|
@@ -372,6 +373,9 @@ class BrowserClient {
|
|
|
372
373
|
runtime: "browser",
|
|
373
374
|
page: { url: this.pageUrl(), path: this.currentRoute() },
|
|
374
375
|
};
|
|
376
|
+
// The IP is read server-side from the connection, so all the SDK can do is ask for it not to be kept.
|
|
377
|
+
if (!this.config.captureIp)
|
|
378
|
+
metadata.captureIp = false;
|
|
375
379
|
if (this.user && (this.user.id || this.user.username))
|
|
376
380
|
metadata.user = { ...this.user };
|
|
377
381
|
if (Object.keys(this.tags).length)
|
package/dist/browser/types.d.ts
CHANGED
|
@@ -42,6 +42,13 @@ export interface MidlineBrowserConfig {
|
|
|
42
42
|
* funnel view in the Midline dashboard is built from. Default true.
|
|
43
43
|
*/
|
|
44
44
|
capturePageviews?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* Let Midline record the visitor's IP address. Default true. Your visitors' IP is read by the
|
|
47
|
+
* Midline server from the connection — the browser can't know it — so set this to false to ask
|
|
48
|
+
* the server not to keep it. Needs a Midline server that supports it; an older one ignores the
|
|
49
|
+
* request and keeps the address.
|
|
50
|
+
*/
|
|
51
|
+
captureIp?: boolean;
|
|
45
52
|
/**
|
|
46
53
|
* Requests that get a W3C `traceparent` header, so the backend's midline-agent
|
|
47
54
|
* links its request to this page. Strings match as URL prefixes (or path prefixes
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in step with package.json by test/browser.test.js; a browser bundle can't read package.json. */
|
|
2
|
-
export declare const BROWSER_SDK_VERSION = "0.
|
|
2
|
+
export declare const BROWSER_SDK_VERSION = "0.7.0";
|
package/dist/browser/version.js
CHANGED
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
3
|
exports.BROWSER_SDK_VERSION = void 0;
|
|
4
4
|
/** Kept in step with package.json by test/browser.test.js; a browser bundle can't read package.json. */
|
|
5
|
-
exports.BROWSER_SDK_VERSION = "0.
|
|
5
|
+
exports.BROWSER_SDK_VERSION = "0.7.0";
|
package/dist/cli.js
CHANGED
|
@@ -4,6 +4,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
|
|
|
4
4
|
const agent_1 = require("./agent");
|
|
5
5
|
const config_1 = require("./config");
|
|
6
6
|
const proxy_1 = require("./proxy");
|
|
7
|
+
const sourcemaps_1 = require("./sourcemaps");
|
|
7
8
|
const USAGE = `Usage: midline-agent proxy [--target <url>] [--port <n>] [--host <addr>]
|
|
8
9
|
|
|
9
10
|
Forwards traffic to a destination API and reports every exchange to Midline.
|
|
@@ -17,6 +18,18 @@ Midline server:
|
|
|
17
18
|
MIDLINE_ENDPOINT default https://api.usemidline.com
|
|
18
19
|
MIDLINE_CUSTOM_CA extra CA for the Midline server only (PEM or file path)
|
|
19
20
|
TARGET_API_CA extra CA for the destination only (PEM or file path)
|
|
21
|
+
|
|
22
|
+
Usage: midline-agent sourcemaps upload <dir> --release <name> [--url-prefix <prefix>] [--dry-run]
|
|
23
|
+
|
|
24
|
+
Uploads every .map file under <dir> so stack traces from that release show your
|
|
25
|
+
original code. Run it in CI after your build, with the same release name your SDK
|
|
26
|
+
reports.
|
|
27
|
+
|
|
28
|
+
--release Release the build belongs to (required)
|
|
29
|
+
--url-prefix What the built files are served under (default "~", any origin)
|
|
30
|
+
--endpoint Midline server (MIDLINE_ENDPOINT)
|
|
31
|
+
--api-key A project SERVER key, pk_ keys are refused (MIDLINE_API_KEY)
|
|
32
|
+
--dry-run List what would be uploaded
|
|
20
33
|
`;
|
|
21
34
|
function flag(args, name) {
|
|
22
35
|
const index = args.indexOf(`--${name}`);
|
|
@@ -25,8 +38,30 @@ function flag(args, name) {
|
|
|
25
38
|
const inline = args.find((arg) => arg.startsWith(`--${name}=`));
|
|
26
39
|
return inline?.slice(name.length + 3);
|
|
27
40
|
}
|
|
41
|
+
async function sourcemaps(args) {
|
|
42
|
+
const dir = args[2] && !args[2].startsWith("--") ? args[2] : undefined;
|
|
43
|
+
if (args[1] !== "upload" || !dir || args.includes("--help") || args.includes("-h")) {
|
|
44
|
+
process.stdout.write(USAGE);
|
|
45
|
+
process.exitCode = args.includes("--help") || args.includes("-h") ? 0 : 1;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const result = await (0, sourcemaps_1.uploadSourceMaps)({
|
|
49
|
+
dir,
|
|
50
|
+
release: flag(args, "release") ?? "",
|
|
51
|
+
urlPrefix: flag(args, "url-prefix"),
|
|
52
|
+
endpoint: flag(args, "endpoint"),
|
|
53
|
+
apiKey: flag(args, "api-key"),
|
|
54
|
+
dryRun: args.includes("--dry-run"),
|
|
55
|
+
log: (line) => process.stdout.write(`midline sourcemaps: ${line}\n`),
|
|
56
|
+
});
|
|
57
|
+
process.stdout.write(`midline sourcemaps: ${result.uploaded.length} ${args.includes("--dry-run") ? "listed" : "uploaded"}, ${result.failed.length} failed\n`);
|
|
58
|
+
if (result.failed.length)
|
|
59
|
+
process.exitCode = 1;
|
|
60
|
+
}
|
|
28
61
|
async function main() {
|
|
29
62
|
const args = process.argv.slice(2);
|
|
63
|
+
if (args[0] === "sourcemaps")
|
|
64
|
+
return sourcemaps(args);
|
|
30
65
|
if (args[0] !== "proxy" || args.includes("--help") || args.includes("-h")) {
|
|
31
66
|
process.stdout.write(USAGE);
|
|
32
67
|
process.exitCode = args[0] === "proxy" || args.length === 0 ? 0 : 1;
|
|
@@ -58,6 +93,6 @@ async function main() {
|
|
|
58
93
|
process.on("SIGTERM", () => stop("SIGTERM"));
|
|
59
94
|
}
|
|
60
95
|
main().catch((err) => {
|
|
61
|
-
process.stderr.write(`midline proxy: ${err instanceof config_1.ConfigError ? err.message : err?.stack ?? err}\n`);
|
|
96
|
+
process.stderr.write(`midline ${process.argv[2] === "sourcemaps" ? "sourcemaps" : "proxy"}: ${err instanceof config_1.ConfigError ? err.message : err?.stack ?? err}\n`);
|
|
62
97
|
process.exit(1);
|
|
63
98
|
});
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import type { IncomingMessage } from "http";
|
|
2
|
+
/**
|
|
3
|
+
* How many reverse proxies (load balancer, CDN) sit between the internet and this server.
|
|
4
|
+
* `true` trusts the whole `X-Forwarded-For` chain, so the leftmost entry is the client.
|
|
5
|
+
*/
|
|
6
|
+
export type TrustProxy = boolean | number;
|
|
7
|
+
type WithExpressIp = IncomingMessage & {
|
|
8
|
+
ip?: string;
|
|
9
|
+
};
|
|
10
|
+
/**
|
|
11
|
+
* The address of whoever called this server.
|
|
12
|
+
*
|
|
13
|
+
* Without `trustProxy` this is Express's `req.ip` (which follows the app's own `trust proxy`
|
|
14
|
+
* setting) or the socket's peer. Behind a load balancer that is the load balancer, not the
|
|
15
|
+
* user — so with `trustProxy` the client is read from `X-Forwarded-For` instead, counting
|
|
16
|
+
* `trustProxy` hops in from the end of the chain: the entries a client prepends itself
|
|
17
|
+
* are ignored, and only what your own proxies appended is believed.
|
|
18
|
+
*/
|
|
19
|
+
export declare function clientIp(req: WithExpressIp, trustProxy?: TrustProxy): string | undefined;
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.clientIp = clientIp;
|
|
4
|
+
const tap_1 = require("./tap");
|
|
5
|
+
/**
|
|
6
|
+
* The address of whoever called this server.
|
|
7
|
+
*
|
|
8
|
+
* Without `trustProxy` this is Express's `req.ip` (which follows the app's own `trust proxy`
|
|
9
|
+
* setting) or the socket's peer. Behind a load balancer that is the load balancer, not the
|
|
10
|
+
* user — so with `trustProxy` the client is read from `X-Forwarded-For` instead, counting
|
|
11
|
+
* `trustProxy` hops in from the end of the chain: the entries a client prepends itself
|
|
12
|
+
* are ignored, and only what your own proxies appended is believed.
|
|
13
|
+
*/
|
|
14
|
+
function clientIp(req, trustProxy) {
|
|
15
|
+
const peer = req.socket?.remoteAddress;
|
|
16
|
+
if (trustProxy === undefined || trustProxy === false || trustProxy === 0) {
|
|
17
|
+
return req.ip ?? peer;
|
|
18
|
+
}
|
|
19
|
+
const forwarded = ((0, tap_1.headerValue)(req.headers["x-forwarded-for"]) ?? "")
|
|
20
|
+
.split(",")
|
|
21
|
+
.map((entry) => entry.trim())
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
const chain = peer ? [...forwarded, peer] : forwarded;
|
|
24
|
+
if (!chain.length) {
|
|
25
|
+
return undefined;
|
|
26
|
+
}
|
|
27
|
+
const index = trustProxy === true ? 0 : Math.max(0, chain.length - 1 - trustProxy);
|
|
28
|
+
return chain[index];
|
|
29
|
+
}
|
package/dist/config.d.ts
CHANGED
package/dist/config.js
CHANGED
|
@@ -205,6 +205,7 @@ function resolveConfig(config) {
|
|
|
205
205
|
redactHeaders: config.redactHeaders ?? [],
|
|
206
206
|
capture: resolveCapture(config.capture),
|
|
207
207
|
captureConsole: config.captureConsole ?? envFlag("MIDLINE_CAPTURE_CONSOLE") ?? false,
|
|
208
|
+
captureIp: config.captureIp ?? envFlag("MIDLINE_CAPTURE_IP") ?? true,
|
|
208
209
|
onError: config.onError,
|
|
209
210
|
debug: config.debug ?? envFlag("MIDLINE_DEBUG") ?? false,
|
|
210
211
|
flushIntervalMs: positiveInt(config.flushIntervalMs, 1500, 50, 60000),
|
|
@@ -76,6 +76,7 @@ function resolve(config) {
|
|
|
76
76
|
consoleLevels: levels.filter((level) => level in CONSOLE_SEVERITY),
|
|
77
77
|
captureWebVitals: config.captureWebVitals !== false,
|
|
78
78
|
capturePageviews: config.capturePageviews !== false,
|
|
79
|
+
captureIp: config.captureIp !== false,
|
|
79
80
|
tracePropagationTargets: config.tracePropagationTargets,
|
|
80
81
|
ignoreErrors: config.ignoreErrors ?? [],
|
|
81
82
|
ignoreUrls: config.ignoreUrls ?? [],
|
|
@@ -366,6 +367,9 @@ export class BrowserClient {
|
|
|
366
367
|
runtime: "browser",
|
|
367
368
|
page: { url: this.pageUrl(), path: this.currentRoute() },
|
|
368
369
|
};
|
|
370
|
+
// The IP is read server-side from the connection, so all the SDK can do is ask for it not to be kept.
|
|
371
|
+
if (!this.config.captureIp)
|
|
372
|
+
metadata.captureIp = false;
|
|
369
373
|
if (this.user && (this.user.id || this.user.username))
|
|
370
374
|
metadata.user = { ...this.user };
|
|
371
375
|
if (Object.keys(this.tags).length)
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in step with package.json by test/browser.test.js; a browser bundle can't read package.json. */
|
|
2
|
-
export const BROWSER_SDK_VERSION = "0.
|
|
2
|
+
export const BROWSER_SDK_VERSION = "0.7.0";
|
package/dist/middleware.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "http";
|
|
2
2
|
import { MidlineAgent } from "./agent";
|
|
3
|
+
import { TrustProxy } from "./client-ip";
|
|
3
4
|
import { CaptureOptions } from "./types";
|
|
4
5
|
export interface MidlineMiddlewareOptions {
|
|
5
6
|
/** Defaults to the agent created by `MidlineAgent.init()`, looked up per request. */
|
|
@@ -8,6 +9,14 @@ export interface MidlineMiddlewareOptions {
|
|
|
8
9
|
capture?: CaptureOptions;
|
|
9
10
|
/** Return true to leave a request out, e.g. health checks. */
|
|
10
11
|
ignore?: (req: IncomingMessage) => boolean;
|
|
12
|
+
/**
|
|
13
|
+
* How many reverse proxies (load balancer, CDN) sit in front of this server. Set it so the
|
|
14
|
+
* recorded IP is the end user's rather than the proxy's; `1` for one load balancer. Left unset,
|
|
15
|
+
* the address is Express's `req.ip`, which follows your app's own `trust proxy` setting.
|
|
16
|
+
* `true` trusts the whole `X-Forwarded-For` chain — only safe if nothing reaches the server
|
|
17
|
+
* except through your proxies, since a client could otherwise claim any address.
|
|
18
|
+
*/
|
|
19
|
+
trustProxy?: TrustProxy;
|
|
11
20
|
}
|
|
12
21
|
/**
|
|
13
22
|
* Records every request that passes through. Mount it before your routes.
|
package/dist/middleware.js
CHANGED
|
@@ -4,6 +4,7 @@ exports.midlineMiddleware = midlineMiddleware;
|
|
|
4
4
|
const agent_1 = require("./agent");
|
|
5
5
|
const breadcrumbs_1 = require("./breadcrumbs");
|
|
6
6
|
const config_1 = require("./config");
|
|
7
|
+
const client_ip_1 = require("./client-ip");
|
|
7
8
|
const context_1 = require("./context");
|
|
8
9
|
const tap_1 = require("./tap");
|
|
9
10
|
/**
|
|
@@ -59,7 +60,7 @@ function observe(req, res, options, captureOverride) {
|
|
|
59
60
|
url,
|
|
60
61
|
statusCode,
|
|
61
62
|
durationMs: Number(process.hrtime.bigint() - started) / 1e6,
|
|
62
|
-
ip: req
|
|
63
|
+
ip: (0, client_ip_1.clientIp)(req, options.trustProxy),
|
|
63
64
|
userAgent: (0, tap_1.headerValue)(req.headers["user-agent"]),
|
|
64
65
|
routeTemplate: typeof routePath === "string" ? `${req.baseUrl ?? ""}${routePath}` : undefined,
|
|
65
66
|
aborted,
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export interface UploadOptions {
|
|
2
|
+
/** Directory containing the build output (searched recursively for `*.map`). */
|
|
3
|
+
dir: string;
|
|
4
|
+
/** The release the build belongs to: the same string passed as `release` to the SDK. */
|
|
5
|
+
release: string;
|
|
6
|
+
/** What the built files are served under. Default `~`, meaning "any origin, this path". */
|
|
7
|
+
urlPrefix?: string;
|
|
8
|
+
/** Midline server. Env fallback: `MIDLINE_ENDPOINT`. */
|
|
9
|
+
endpoint?: string;
|
|
10
|
+
/** A project's **server** API key. Env fallback: `MIDLINE_API_KEY`. Browser keys are refused by the server. */
|
|
11
|
+
apiKey?: string;
|
|
12
|
+
dryRun?: boolean;
|
|
13
|
+
log?: (line: string) => void;
|
|
14
|
+
}
|
|
15
|
+
export interface UploadResult {
|
|
16
|
+
uploaded: string[];
|
|
17
|
+
failed: Array<{
|
|
18
|
+
name: string;
|
|
19
|
+
error: string;
|
|
20
|
+
}>;
|
|
21
|
+
}
|
|
22
|
+
/** Every `*.map` under `dir`, without descending into node_modules. */
|
|
23
|
+
export declare function findMaps(dir: string, depth?: number): Promise<string[]>;
|
|
24
|
+
/** `dist/assets/app-3f2a.js.map` -> `~/assets/app-3f2a.js`: the built file the map describes, as the server will match it. */
|
|
25
|
+
export declare function uploadName(dir: string, mapFile: string, urlPrefix?: string): string;
|
|
26
|
+
export declare function uploadSourceMaps(options: UploadOptions): Promise<UploadResult>;
|
|
@@ -0,0 +1,118 @@
|
|
|
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.findMaps = findMaps;
|
|
37
|
+
exports.uploadName = uploadName;
|
|
38
|
+
exports.uploadSourceMaps = uploadSourceMaps;
|
|
39
|
+
const promises_1 = require("fs/promises");
|
|
40
|
+
const path = __importStar(require("path"));
|
|
41
|
+
const config_1 = require("./config");
|
|
42
|
+
const MAX_DEPTH = 20;
|
|
43
|
+
const CONCURRENCY = 4;
|
|
44
|
+
/** Every `*.map` under `dir`, without descending into node_modules. */
|
|
45
|
+
async function findMaps(dir, depth = 0) {
|
|
46
|
+
if (depth > MAX_DEPTH)
|
|
47
|
+
return [];
|
|
48
|
+
const entries = await (0, promises_1.readdir)(dir, { withFileTypes: true });
|
|
49
|
+
const found = [];
|
|
50
|
+
for (const entry of entries) {
|
|
51
|
+
const full = path.join(dir, entry.name);
|
|
52
|
+
if (entry.isDirectory()) {
|
|
53
|
+
if (entry.name !== "node_modules")
|
|
54
|
+
found.push(...(await findMaps(full, depth + 1)));
|
|
55
|
+
}
|
|
56
|
+
else if (entry.isFile() && entry.name.endsWith(".map")) {
|
|
57
|
+
found.push(full);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return found.sort();
|
|
61
|
+
}
|
|
62
|
+
/** `dist/assets/app-3f2a.js.map` -> `~/assets/app-3f2a.js`: the built file the map describes, as the server will match it. */
|
|
63
|
+
function uploadName(dir, mapFile, urlPrefix = "~") {
|
|
64
|
+
const relative = path.relative(dir, mapFile).split(path.sep).join("/").replace(/\.map$/, "");
|
|
65
|
+
return `${urlPrefix.replace(/\/+$/, "")}/${relative}`;
|
|
66
|
+
}
|
|
67
|
+
async function uploadSourceMaps(options) {
|
|
68
|
+
const log = options.log ?? (() => undefined);
|
|
69
|
+
if (!options.release?.trim())
|
|
70
|
+
throw new config_1.ConfigError("--release is required");
|
|
71
|
+
const apiKey = options.apiKey ?? (0, config_1.env)("MIDLINE_API_KEY");
|
|
72
|
+
if (!apiKey && !options.dryRun)
|
|
73
|
+
throw new config_1.ConfigError("Set MIDLINE_API_KEY (a project server key) or pass --api-key");
|
|
74
|
+
const origin = (0, config_1.resolveEndpointOrigin)(options.endpoint ?? (0, config_1.env)("MIDLINE_ENDPOINT") ?? config_1.DEFAULT_ENDPOINT);
|
|
75
|
+
const maps = await findMaps(options.dir).catch((error) => {
|
|
76
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR")
|
|
77
|
+
throw new config_1.ConfigError(`${options.dir} is not a directory`);
|
|
78
|
+
throw error;
|
|
79
|
+
});
|
|
80
|
+
if (!maps.length)
|
|
81
|
+
throw new config_1.ConfigError(`No .map files found under ${options.dir}. Enable source maps in your build.`);
|
|
82
|
+
const result = { uploaded: [], failed: [] };
|
|
83
|
+
const queue = [...maps];
|
|
84
|
+
const worker = async () => {
|
|
85
|
+
for (let file = queue.shift(); file; file = queue.shift()) {
|
|
86
|
+
const name = uploadName(options.dir, file, options.urlPrefix);
|
|
87
|
+
if (options.dryRun) {
|
|
88
|
+
log(`would upload ${name}`);
|
|
89
|
+
result.uploaded.push(name);
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
try {
|
|
93
|
+
const body = new FormData();
|
|
94
|
+
body.append("release", options.release);
|
|
95
|
+
body.append("name", name);
|
|
96
|
+
body.append("file", new Blob([await (0, promises_1.readFile)(file)]), path.basename(file));
|
|
97
|
+
const response = await fetch(new URL("/api/sourcemaps/upload", origin), {
|
|
98
|
+
method: "POST",
|
|
99
|
+
headers: { "x-midline-key": apiKey },
|
|
100
|
+
body,
|
|
101
|
+
});
|
|
102
|
+
if (!response.ok) {
|
|
103
|
+
const detail = await response.json().catch(() => undefined);
|
|
104
|
+
throw new Error(`${response.status} ${detail?.message ?? response.statusText}`);
|
|
105
|
+
}
|
|
106
|
+
log(`uploaded ${name}`);
|
|
107
|
+
result.uploaded.push(name);
|
|
108
|
+
}
|
|
109
|
+
catch (error) {
|
|
110
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
111
|
+
log(`failed ${name}: ${message}`);
|
|
112
|
+
result.failed.push({ name, error: message });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
};
|
|
116
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, maps.length) }, worker));
|
|
117
|
+
return result;
|
|
118
|
+
}
|
package/dist/types.d.ts
CHANGED
|
@@ -62,6 +62,12 @@ export interface MidlineConfig {
|
|
|
62
62
|
* startup lines. Off by default. Env fallback: `MIDLINE_CAPTURE_CONSOLE`.
|
|
63
63
|
*/
|
|
64
64
|
captureConsole?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Record the caller's IP address on request events. Default true. Set to false to
|
|
67
|
+
* leave it out entirely, e.g. where IPs count as personal data you don't want to hold:
|
|
68
|
+
* the address is dropped in this process and never sent. Env fallback: `MIDLINE_CAPTURE_IP`.
|
|
69
|
+
*/
|
|
70
|
+
captureIp?: boolean;
|
|
65
71
|
/** Set to false to keep the agent inert. Env fallback: `MIDLINE_ENABLED`. */
|
|
66
72
|
enabled?: boolean;
|
|
67
73
|
/**
|
package/package.json
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "midline-agent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Midline
|
|
3
|
+
"version": "0.7.0",
|
|
4
|
+
"description": "Midline \u2014 request, error and security monitoring for Node, and error, network, Web Vitals and pageview/visitor monitoring for browsers",
|
|
5
|
+
"homepage": "https://usemidline.com",
|
|
5
6
|
"main": "dist/index.js",
|
|
6
7
|
"types": "dist/index.d.ts",
|
|
7
8
|
"exports": {
|
|
@@ -38,16 +39,32 @@
|
|
|
38
39
|
},
|
|
39
40
|
"keywords": [
|
|
40
41
|
"midline",
|
|
42
|
+
"api-monitoring",
|
|
43
|
+
"api-observability",
|
|
44
|
+
"apm",
|
|
45
|
+
"browser",
|
|
46
|
+
"console-capture",
|
|
47
|
+
"error-tracking",
|
|
48
|
+
"errors",
|
|
41
49
|
"express",
|
|
50
|
+
"express-middleware",
|
|
51
|
+
"frontend",
|
|
52
|
+
"http-logging",
|
|
53
|
+
"issue-tracking",
|
|
54
|
+
"log-redaction",
|
|
42
55
|
"monitoring",
|
|
56
|
+
"nestjs",
|
|
57
|
+
"nodejs",
|
|
58
|
+
"observability",
|
|
59
|
+
"proxy",
|
|
60
|
+
"real-user-monitoring",
|
|
61
|
+
"request",
|
|
62
|
+
"request-logging",
|
|
63
|
+
"reverse-proxy",
|
|
64
|
+
"rum",
|
|
43
65
|
"sdk",
|
|
44
66
|
"security",
|
|
45
|
-
"
|
|
46
|
-
"errors",
|
|
47
|
-
"proxy",
|
|
48
|
-
"browser",
|
|
49
|
-
"web-vitals",
|
|
50
|
-
"frontend"
|
|
67
|
+
"web-vitals"
|
|
51
68
|
],
|
|
52
69
|
"author": "Your Name",
|
|
53
70
|
"license": "MIT",
|
package/src/agent.ts
CHANGED
|
@@ -433,7 +433,7 @@ export class MidlineAgent {
|
|
|
433
433
|
timestamp: validTimestamp(event.timestamp),
|
|
434
434
|
// Clamped to the server's validation limits: one over-long field would
|
|
435
435
|
// otherwise get every event rejected.
|
|
436
|
-
ip: clamp(event.ip, 64),
|
|
436
|
+
ip: config.captureIp ? clamp(event.ip, 64) : undefined,
|
|
437
437
|
userAgent: clamp(event.userAgent, 512),
|
|
438
438
|
service: clamp(config.serviceName, 128),
|
|
439
439
|
environment: clamp(config.environment, 128),
|
package/src/browser/client.ts
CHANGED
|
@@ -59,6 +59,7 @@ interface Resolved {
|
|
|
59
59
|
consoleLevels: ConsoleLevel[];
|
|
60
60
|
captureWebVitals: boolean;
|
|
61
61
|
capturePageviews: boolean;
|
|
62
|
+
captureIp: boolean;
|
|
62
63
|
tracePropagationTargets?: Array<string | RegExp>;
|
|
63
64
|
ignoreErrors: Array<string | RegExp>;
|
|
64
65
|
ignoreUrls: Array<string | RegExp>;
|
|
@@ -126,6 +127,7 @@ function resolve(config: MidlineBrowserConfig): Resolved {
|
|
|
126
127
|
consoleLevels: levels.filter((level) => level in CONSOLE_SEVERITY),
|
|
127
128
|
captureWebVitals: config.captureWebVitals !== false,
|
|
128
129
|
capturePageviews: config.capturePageviews !== false,
|
|
130
|
+
captureIp: config.captureIp !== false,
|
|
129
131
|
tracePropagationTargets: config.tracePropagationTargets,
|
|
130
132
|
ignoreErrors: config.ignoreErrors ?? [],
|
|
131
133
|
ignoreUrls: config.ignoreUrls ?? [],
|
|
@@ -454,6 +456,8 @@ export class BrowserClient {
|
|
|
454
456
|
runtime: "browser",
|
|
455
457
|
page: { url: this.pageUrl(), path: this.currentRoute() },
|
|
456
458
|
};
|
|
459
|
+
// The IP is read server-side from the connection, so all the SDK can do is ask for it not to be kept.
|
|
460
|
+
if (!this.config.captureIp) metadata.captureIp = false;
|
|
457
461
|
if (this.user && (this.user.id || this.user.username)) metadata.user = { ...this.user };
|
|
458
462
|
if (Object.keys(this.tags).length) metadata.tags = { ...this.tags };
|
|
459
463
|
const nav = this.win.navigator;
|
package/src/browser/types.ts
CHANGED
|
@@ -48,6 +48,13 @@ export interface MidlineBrowserConfig {
|
|
|
48
48
|
* funnel view in the Midline dashboard is built from. Default true.
|
|
49
49
|
*/
|
|
50
50
|
capturePageviews?: boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Let Midline record the visitor's IP address. Default true. Your visitors' IP is read by the
|
|
53
|
+
* Midline server from the connection — the browser can't know it — so set this to false to ask
|
|
54
|
+
* the server not to keep it. Needs a Midline server that supports it; an older one ignores the
|
|
55
|
+
* request and keeps the address.
|
|
56
|
+
*/
|
|
57
|
+
captureIp?: boolean;
|
|
51
58
|
|
|
52
59
|
/**
|
|
53
60
|
* Requests that get a W3C `traceparent` header, so the backend's midline-agent
|
package/src/browser/version.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in step with package.json by test/browser.test.js; a browser bundle can't read package.json. */
|
|
2
|
-
export const BROWSER_SDK_VERSION = "0.
|
|
2
|
+
export const BROWSER_SDK_VERSION = "0.7.0";
|
package/src/cli.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { MidlineAgent } from "./agent";
|
|
3
3
|
import { ConfigError } from "./config";
|
|
4
4
|
import { proxyAddress, resolveTarget, startMidlineProxy } from "./proxy";
|
|
5
|
+
import { uploadSourceMaps } from "./sourcemaps";
|
|
5
6
|
|
|
6
7
|
const USAGE = `Usage: midline-agent proxy [--target <url>] [--port <n>] [--host <addr>]
|
|
7
8
|
|
|
@@ -16,6 +17,18 @@ Midline server:
|
|
|
16
17
|
MIDLINE_ENDPOINT default https://api.usemidline.com
|
|
17
18
|
MIDLINE_CUSTOM_CA extra CA for the Midline server only (PEM or file path)
|
|
18
19
|
TARGET_API_CA extra CA for the destination only (PEM or file path)
|
|
20
|
+
|
|
21
|
+
Usage: midline-agent sourcemaps upload <dir> --release <name> [--url-prefix <prefix>] [--dry-run]
|
|
22
|
+
|
|
23
|
+
Uploads every .map file under <dir> so stack traces from that release show your
|
|
24
|
+
original code. Run it in CI after your build, with the same release name your SDK
|
|
25
|
+
reports.
|
|
26
|
+
|
|
27
|
+
--release Release the build belongs to (required)
|
|
28
|
+
--url-prefix What the built files are served under (default "~", any origin)
|
|
29
|
+
--endpoint Midline server (MIDLINE_ENDPOINT)
|
|
30
|
+
--api-key A project SERVER key, pk_ keys are refused (MIDLINE_API_KEY)
|
|
31
|
+
--dry-run List what would be uploaded
|
|
19
32
|
`;
|
|
20
33
|
|
|
21
34
|
function flag(args: string[], name: string): string | undefined {
|
|
@@ -25,8 +38,29 @@ function flag(args: string[], name: string): string | undefined {
|
|
|
25
38
|
return inline?.slice(name.length + 3);
|
|
26
39
|
}
|
|
27
40
|
|
|
41
|
+
async function sourcemaps(args: string[]): Promise<void> {
|
|
42
|
+
const dir = args[2] && !args[2].startsWith("--") ? args[2] : undefined;
|
|
43
|
+
if (args[1] !== "upload" || !dir || args.includes("--help") || args.includes("-h")) {
|
|
44
|
+
process.stdout.write(USAGE);
|
|
45
|
+
process.exitCode = args.includes("--help") || args.includes("-h") ? 0 : 1;
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
const result = await uploadSourceMaps({
|
|
49
|
+
dir,
|
|
50
|
+
release: flag(args, "release") ?? "",
|
|
51
|
+
urlPrefix: flag(args, "url-prefix"),
|
|
52
|
+
endpoint: flag(args, "endpoint"),
|
|
53
|
+
apiKey: flag(args, "api-key"),
|
|
54
|
+
dryRun: args.includes("--dry-run"),
|
|
55
|
+
log: (line) => process.stdout.write(`midline sourcemaps: ${line}\n`),
|
|
56
|
+
});
|
|
57
|
+
process.stdout.write(`midline sourcemaps: ${result.uploaded.length} ${args.includes("--dry-run") ? "listed" : "uploaded"}, ${result.failed.length} failed\n`);
|
|
58
|
+
if (result.failed.length) process.exitCode = 1;
|
|
59
|
+
}
|
|
60
|
+
|
|
28
61
|
async function main(): Promise<void> {
|
|
29
62
|
const args = process.argv.slice(2);
|
|
63
|
+
if (args[0] === "sourcemaps") return sourcemaps(args);
|
|
30
64
|
if (args[0] !== "proxy" || args.includes("--help") || args.includes("-h")) {
|
|
31
65
|
process.stdout.write(USAGE);
|
|
32
66
|
process.exitCode = args[0] === "proxy" || args.length === 0 ? 0 : 1;
|
|
@@ -64,6 +98,6 @@ async function main(): Promise<void> {
|
|
|
64
98
|
}
|
|
65
99
|
|
|
66
100
|
main().catch((err) => {
|
|
67
|
-
process.stderr.write(`midline proxy: ${err instanceof ConfigError ? err.message : err?.stack ?? err}\n`);
|
|
101
|
+
process.stderr.write(`midline ${process.argv[2] === "sourcemaps" ? "sourcemaps" : "proxy"}: ${err instanceof ConfigError ? err.message : err?.stack ?? err}\n`);
|
|
68
102
|
process.exit(1);
|
|
69
103
|
});
|
package/src/client-ip.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import type { IncomingMessage } from "http";
|
|
2
|
+
import { headerValue } from "./tap";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* How many reverse proxies (load balancer, CDN) sit between the internet and this server.
|
|
6
|
+
* `true` trusts the whole `X-Forwarded-For` chain, so the leftmost entry is the client.
|
|
7
|
+
*/
|
|
8
|
+
export type TrustProxy = boolean | number;
|
|
9
|
+
|
|
10
|
+
type WithExpressIp = IncomingMessage & { ip?: string };
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The address of whoever called this server.
|
|
14
|
+
*
|
|
15
|
+
* Without `trustProxy` this is Express's `req.ip` (which follows the app's own `trust proxy`
|
|
16
|
+
* setting) or the socket's peer. Behind a load balancer that is the load balancer, not the
|
|
17
|
+
* user — so with `trustProxy` the client is read from `X-Forwarded-For` instead, counting
|
|
18
|
+
* `trustProxy` hops in from the end of the chain: the entries a client prepends itself
|
|
19
|
+
* are ignored, and only what your own proxies appended is believed.
|
|
20
|
+
*/
|
|
21
|
+
export function clientIp(req: WithExpressIp, trustProxy?: TrustProxy): string | undefined {
|
|
22
|
+
const peer = req.socket?.remoteAddress;
|
|
23
|
+
if (trustProxy === undefined || trustProxy === false || trustProxy === 0) {
|
|
24
|
+
return req.ip ?? peer;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const forwarded = (headerValue(req.headers["x-forwarded-for"]) ?? "")
|
|
28
|
+
.split(",")
|
|
29
|
+
.map((entry) => entry.trim())
|
|
30
|
+
.filter(Boolean);
|
|
31
|
+
const chain = peer ? [...forwarded, peer] : forwarded;
|
|
32
|
+
if (!chain.length) {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const index = trustProxy === true ? 0 : Math.max(0, chain.length - 1 - trustProxy);
|
|
37
|
+
return chain[index];
|
|
38
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -37,6 +37,7 @@ export interface ResolvedConfig {
|
|
|
37
37
|
redactHeaders: string[];
|
|
38
38
|
capture: ResolvedCapture;
|
|
39
39
|
captureConsole: boolean;
|
|
40
|
+
captureIp: boolean;
|
|
40
41
|
onError?: (message: string) => void;
|
|
41
42
|
debug: boolean;
|
|
42
43
|
flushIntervalMs: number;
|
|
@@ -209,6 +210,7 @@ export function resolveConfig(config: MidlineConfig): ResolvedConfig {
|
|
|
209
210
|
redactHeaders: config.redactHeaders ?? [],
|
|
210
211
|
capture: resolveCapture(config.capture),
|
|
211
212
|
captureConsole: config.captureConsole ?? envFlag("MIDLINE_CAPTURE_CONSOLE") ?? false,
|
|
213
|
+
captureIp: config.captureIp ?? envFlag("MIDLINE_CAPTURE_IP") ?? true,
|
|
212
214
|
onError: config.onError,
|
|
213
215
|
debug: config.debug ?? envFlag("MIDLINE_DEBUG") ?? false,
|
|
214
216
|
flushIntervalMs: positiveInt(config.flushIntervalMs, 1500, 50, 60_000),
|
package/src/middleware.ts
CHANGED
|
@@ -2,6 +2,7 @@ import type { IncomingMessage, ServerResponse } from "http";
|
|
|
2
2
|
import { MidlineAgent } from "./agent";
|
|
3
3
|
import { addBreadcrumb } from "./breadcrumbs";
|
|
4
4
|
import { ResolvedCapture, resolveCapture } from "./config";
|
|
5
|
+
import { clientIp, TrustProxy } from "./client-ip";
|
|
5
6
|
import { createRequestContext, setRequestContext } from "./context";
|
|
6
7
|
import { BodyTap, headerValue, isCompressed } from "./tap";
|
|
7
8
|
import { CaptureOptions } from "./types";
|
|
@@ -13,6 +14,14 @@ export interface MidlineMiddlewareOptions {
|
|
|
13
14
|
capture?: CaptureOptions;
|
|
14
15
|
/** Return true to leave a request out, e.g. health checks. */
|
|
15
16
|
ignore?: (req: IncomingMessage) => boolean;
|
|
17
|
+
/**
|
|
18
|
+
* How many reverse proxies (load balancer, CDN) sit in front of this server. Set it so the
|
|
19
|
+
* recorded IP is the end user's rather than the proxy's; `1` for one load balancer. Left unset,
|
|
20
|
+
* the address is Express's `req.ip`, which follows your app's own `trust proxy` setting.
|
|
21
|
+
* `true` trusts the whole `X-Forwarded-For` chain — only safe if nothing reaches the server
|
|
22
|
+
* except through your proxies, since a client could otherwise claim any address.
|
|
23
|
+
*/
|
|
24
|
+
trustProxy?: TrustProxy;
|
|
16
25
|
}
|
|
17
26
|
|
|
18
27
|
/** The Express additions this reads when they exist. Plain `http` requests work too. */
|
|
@@ -87,7 +96,7 @@ function observe(
|
|
|
87
96
|
url,
|
|
88
97
|
statusCode,
|
|
89
98
|
durationMs: Number(process.hrtime.bigint() - started) / 1e6,
|
|
90
|
-
ip: req
|
|
99
|
+
ip: clientIp(req, options.trustProxy),
|
|
91
100
|
userAgent: headerValue(req.headers["user-agent"]),
|
|
92
101
|
routeTemplate: typeof routePath === "string" ? `${req.baseUrl ?? ""}${routePath}` : undefined,
|
|
93
102
|
aborted,
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import { readdir, readFile } from "fs/promises";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { DEFAULT_ENDPOINT, ConfigError, env, resolveEndpointOrigin } from "./config";
|
|
4
|
+
|
|
5
|
+
export interface UploadOptions {
|
|
6
|
+
/** Directory containing the build output (searched recursively for `*.map`). */
|
|
7
|
+
dir: string;
|
|
8
|
+
/** The release the build belongs to: the same string passed as `release` to the SDK. */
|
|
9
|
+
release: string;
|
|
10
|
+
/** What the built files are served under. Default `~`, meaning "any origin, this path". */
|
|
11
|
+
urlPrefix?: string;
|
|
12
|
+
/** Midline server. Env fallback: `MIDLINE_ENDPOINT`. */
|
|
13
|
+
endpoint?: string;
|
|
14
|
+
/** A project's **server** API key. Env fallback: `MIDLINE_API_KEY`. Browser keys are refused by the server. */
|
|
15
|
+
apiKey?: string;
|
|
16
|
+
dryRun?: boolean;
|
|
17
|
+
log?: (line: string) => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface UploadResult {
|
|
21
|
+
uploaded: string[];
|
|
22
|
+
failed: Array<{ name: string; error: string }>;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const MAX_DEPTH = 20;
|
|
26
|
+
const CONCURRENCY = 4;
|
|
27
|
+
|
|
28
|
+
/** Every `*.map` under `dir`, without descending into node_modules. */
|
|
29
|
+
export async function findMaps(dir: string, depth = 0): Promise<string[]> {
|
|
30
|
+
if (depth > MAX_DEPTH) return [];
|
|
31
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
32
|
+
const found: string[] = [];
|
|
33
|
+
for (const entry of entries) {
|
|
34
|
+
const full = path.join(dir, entry.name);
|
|
35
|
+
if (entry.isDirectory()) {
|
|
36
|
+
if (entry.name !== "node_modules") found.push(...(await findMaps(full, depth + 1)));
|
|
37
|
+
} else if (entry.isFile() && entry.name.endsWith(".map")) {
|
|
38
|
+
found.push(full);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
return found.sort();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** `dist/assets/app-3f2a.js.map` -> `~/assets/app-3f2a.js`: the built file the map describes, as the server will match it. */
|
|
45
|
+
export function uploadName(dir: string, mapFile: string, urlPrefix = "~"): string {
|
|
46
|
+
const relative = path.relative(dir, mapFile).split(path.sep).join("/").replace(/\.map$/, "");
|
|
47
|
+
return `${urlPrefix.replace(/\/+$/, "")}/${relative}`;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function uploadSourceMaps(options: UploadOptions): Promise<UploadResult> {
|
|
51
|
+
const log = options.log ?? (() => undefined);
|
|
52
|
+
if (!options.release?.trim()) throw new ConfigError("--release is required");
|
|
53
|
+
const apiKey = options.apiKey ?? env("MIDLINE_API_KEY");
|
|
54
|
+
if (!apiKey && !options.dryRun) throw new ConfigError("Set MIDLINE_API_KEY (a project server key) or pass --api-key");
|
|
55
|
+
const origin = resolveEndpointOrigin(options.endpoint ?? env("MIDLINE_ENDPOINT") ?? DEFAULT_ENDPOINT);
|
|
56
|
+
|
|
57
|
+
const maps = await findMaps(options.dir).catch((error: NodeJS.ErrnoException) => {
|
|
58
|
+
if (error.code === "ENOENT" || error.code === "ENOTDIR") throw new ConfigError(`${options.dir} is not a directory`);
|
|
59
|
+
throw error;
|
|
60
|
+
});
|
|
61
|
+
if (!maps.length) throw new ConfigError(`No .map files found under ${options.dir}. Enable source maps in your build.`);
|
|
62
|
+
|
|
63
|
+
const result: UploadResult = { uploaded: [], failed: [] };
|
|
64
|
+
const queue = [...maps];
|
|
65
|
+
|
|
66
|
+
const worker = async () => {
|
|
67
|
+
for (let file = queue.shift(); file; file = queue.shift()) {
|
|
68
|
+
const name = uploadName(options.dir, file, options.urlPrefix);
|
|
69
|
+
if (options.dryRun) {
|
|
70
|
+
log(`would upload ${name}`);
|
|
71
|
+
result.uploaded.push(name);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
try {
|
|
75
|
+
const body = new FormData();
|
|
76
|
+
body.append("release", options.release);
|
|
77
|
+
body.append("name", name);
|
|
78
|
+
body.append("file", new Blob([await readFile(file)]), path.basename(file));
|
|
79
|
+
const response = await fetch(new URL("/api/sourcemaps/upload", origin), {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { "x-midline-key": apiKey as string },
|
|
82
|
+
body,
|
|
83
|
+
});
|
|
84
|
+
if (!response.ok) {
|
|
85
|
+
const detail = await response.json().catch(() => undefined);
|
|
86
|
+
throw new Error(`${response.status} ${detail?.message ?? response.statusText}`);
|
|
87
|
+
}
|
|
88
|
+
log(`uploaded ${name}`);
|
|
89
|
+
result.uploaded.push(name);
|
|
90
|
+
} catch (error) {
|
|
91
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
92
|
+
log(`failed ${name}: ${message}`);
|
|
93
|
+
result.failed.push({ name, error: message });
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
};
|
|
97
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, maps.length) }, worker));
|
|
98
|
+
return result;
|
|
99
|
+
}
|
package/src/types.ts
CHANGED
|
@@ -69,6 +69,12 @@ export interface MidlineConfig {
|
|
|
69
69
|
* startup lines. Off by default. Env fallback: `MIDLINE_CAPTURE_CONSOLE`.
|
|
70
70
|
*/
|
|
71
71
|
captureConsole?: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* Record the caller's IP address on request events. Default true. Set to false to
|
|
74
|
+
* leave it out entirely, e.g. where IPs count as personal data you don't want to hold:
|
|
75
|
+
* the address is dropped in this process and never sent. Env fallback: `MIDLINE_CAPTURE_IP`.
|
|
76
|
+
*/
|
|
77
|
+
captureIp?: boolean;
|
|
72
78
|
|
|
73
79
|
/** Set to false to keep the agent inert. Env fallback: `MIDLINE_ENABLED`. */
|
|
74
80
|
enabled?: boolean;
|
package/test/browser.test.js
CHANGED
|
@@ -277,6 +277,19 @@ test("browser: user, tags and a rate limit", async () => {
|
|
|
277
277
|
});
|
|
278
278
|
});
|
|
279
279
|
|
|
280
|
+
test("browser: captureIp false asks the server not to keep the IP, and is otherwise silent", async () => {
|
|
281
|
+
await withSdk({}, async ({ sent }) => {
|
|
282
|
+
Midline.captureMessage("default");
|
|
283
|
+
await Midline.flush();
|
|
284
|
+
assert.equal("captureIp" in sent()[0].metadata, false);
|
|
285
|
+
});
|
|
286
|
+
await withSdk({ captureIp: false }, async ({ sent }) => {
|
|
287
|
+
Midline.captureMessage("private");
|
|
288
|
+
await Midline.flush();
|
|
289
|
+
assert.equal(sent()[0].metadata.captureIp, false);
|
|
290
|
+
});
|
|
291
|
+
});
|
|
292
|
+
|
|
280
293
|
test("browser: console capture is opt-in and never loops", async () => {
|
|
281
294
|
await withSdk({ captureConsole: true }, async ({ win, sent }) => {
|
|
282
295
|
win.console.error("payment failed", { token: "secret-token", amount: 5 });
|
package/test/middleware.test.js
CHANGED
|
@@ -176,3 +176,75 @@ test("plain node http servers work with the same middleware", async () => {
|
|
|
176
176
|
await collector.close();
|
|
177
177
|
}
|
|
178
178
|
});
|
|
179
|
+
|
|
180
|
+
test("express: the recorded IP is the end user's when trustProxy says how many proxies are in front", async () => {
|
|
181
|
+
const collector = await startSocketCollector();
|
|
182
|
+
const agent = new MidlineAgent({ apiKey: "ak_ip", endpoint: collector.url, flushIntervalMs: 60_000 });
|
|
183
|
+
|
|
184
|
+
const serve = async (options) => {
|
|
185
|
+
const app = express();
|
|
186
|
+
app.use(midlineMiddleware({ agent, ...options }));
|
|
187
|
+
app.get("/ping", (_req, res) => res.json({ ok: true }));
|
|
188
|
+
return listen(app);
|
|
189
|
+
};
|
|
190
|
+
const ipAfter = async (server, headers) => {
|
|
191
|
+
const before = collector.events().length;
|
|
192
|
+
await request(`http://127.0.0.1:${server.port}/ping`, { headers });
|
|
193
|
+
await waitFor(() => agent.queued > 0);
|
|
194
|
+
await agent.flush();
|
|
195
|
+
return collector.events().slice(before).pop().ip;
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
const plain = await serve({});
|
|
199
|
+
const oneProxy = await serve({ trustProxy: 1 });
|
|
200
|
+
const wholeChain = await serve({ trustProxy: true });
|
|
201
|
+
|
|
202
|
+
try {
|
|
203
|
+
// No trustProxy and no Express setting: the address the socket saw, never the header.
|
|
204
|
+
assert.match(await ipAfter(plain, { "x-forwarded-for": "203.0.113.50" }), /127\.0\.0\.1$/);
|
|
205
|
+
// One proxy in front: the address it appended, not what the client prepended.
|
|
206
|
+
assert.equal(await ipAfter(oneProxy, { "x-forwarded-for": "6.6.6.6, 203.0.113.50" }), "203.0.113.50");
|
|
207
|
+
assert.equal(await ipAfter(oneProxy, { "x-forwarded-for": "203.0.113.50" }), "203.0.113.50");
|
|
208
|
+
// No header at all: falls back to the socket peer.
|
|
209
|
+
assert.match(await ipAfter(oneProxy, {}), /127\.0\.0\.1$/);
|
|
210
|
+
assert.equal(await ipAfter(wholeChain, { "x-forwarded-for": "203.0.113.50, 10.0.0.1" }), "203.0.113.50");
|
|
211
|
+
} finally {
|
|
212
|
+
agent.close();
|
|
213
|
+
await Promise.all([plain.close(), oneProxy.close(), wholeChain.close(), collector.close()]);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test("express: captureIp false keeps the caller's address out of every event", async () => {
|
|
218
|
+
const collector = await startSocketCollector();
|
|
219
|
+
const record = async (options) => {
|
|
220
|
+
const agent = new MidlineAgent({ apiKey: "ak_noip", endpoint: collector.url, flushIntervalMs: 60_000, ...options });
|
|
221
|
+
const app = express();
|
|
222
|
+
app.use(midlineMiddleware({ agent }));
|
|
223
|
+
app.get("/ping", (_req, res) => res.json({ ok: true }));
|
|
224
|
+
const server = await listen(app);
|
|
225
|
+
try {
|
|
226
|
+
const before = collector.events().length;
|
|
227
|
+
await request(`http://127.0.0.1:${server.port}/ping`);
|
|
228
|
+
await waitFor(() => agent.queued > 0);
|
|
229
|
+
await agent.flush();
|
|
230
|
+
return collector.events().slice(before).pop();
|
|
231
|
+
} finally {
|
|
232
|
+
agent.close();
|
|
233
|
+
await server.close();
|
|
234
|
+
}
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
try {
|
|
238
|
+
assert.match((await record({})).ip, /127\.0\.0\.1$/);
|
|
239
|
+
assert.equal((await record({ captureIp: false })).ip, undefined);
|
|
240
|
+
|
|
241
|
+
process.env.MIDLINE_CAPTURE_IP = "false";
|
|
242
|
+
try {
|
|
243
|
+
assert.equal((await record({})).ip, undefined);
|
|
244
|
+
} finally {
|
|
245
|
+
delete process.env.MIDLINE_CAPTURE_IP;
|
|
246
|
+
}
|
|
247
|
+
} finally {
|
|
248
|
+
await collector.close();
|
|
249
|
+
}
|
|
250
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const test = require("node:test");
|
|
4
|
+
const assert = require("node:assert/strict");
|
|
5
|
+
const fs = require("fs");
|
|
6
|
+
const http = require("http");
|
|
7
|
+
const os = require("os");
|
|
8
|
+
const path = require("path");
|
|
9
|
+
const { findMaps, uploadName, uploadSourceMaps } = require("../dist/sourcemaps");
|
|
10
|
+
|
|
11
|
+
function makeBuild() {
|
|
12
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "midline-maps-"));
|
|
13
|
+
fs.mkdirSync(path.join(dir, "assets"));
|
|
14
|
+
fs.mkdirSync(path.join(dir, "node_modules", "dep"), { recursive: true });
|
|
15
|
+
fs.writeFileSync(path.join(dir, "assets", "app-1.js.map"), '{"version":3,"mappings":"AAAA"}');
|
|
16
|
+
fs.writeFileSync(path.join(dir, "main.js.map"), '{"version":3,"mappings":"AAAA"}');
|
|
17
|
+
fs.writeFileSync(path.join(dir, "assets", "app-1.js"), "//");
|
|
18
|
+
fs.writeFileSync(path.join(dir, "node_modules", "dep", "x.js.map"), "{}");
|
|
19
|
+
return dir;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
test("finds .map files, skipping node_modules, and names them by the built file they describe", async () => {
|
|
23
|
+
const dir = makeBuild();
|
|
24
|
+
const maps = await findMaps(dir);
|
|
25
|
+
assert.deepEqual(maps.map((m) => path.relative(dir, m)), [path.join("assets", "app-1.js.map"), "main.js.map"]);
|
|
26
|
+
assert.equal(uploadName(dir, maps[0]), "~/assets/app-1.js");
|
|
27
|
+
assert.equal(uploadName(dir, maps[1], "https://app.example.com/static/"), "https://app.example.com/static/main.js");
|
|
28
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("uploads each map with the key, release and name, and reports failures", async () => {
|
|
32
|
+
const dir = makeBuild();
|
|
33
|
+
const seen = [];
|
|
34
|
+
const server = http.createServer((req, res) => {
|
|
35
|
+
let body = "";
|
|
36
|
+
req.on("data", (c) => (body += c));
|
|
37
|
+
req.on("end", () => {
|
|
38
|
+
seen.push({ url: req.url, key: req.headers["x-midline-key"], body });
|
|
39
|
+
const failing = body.includes("main.js");
|
|
40
|
+
res.writeHead(failing ? 400 : 201, { "content-type": "application/json" });
|
|
41
|
+
res.end(JSON.stringify(failing ? { message: "That is not a version 3 source map" } : { ok: true }));
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
45
|
+
const endpoint = `http://127.0.0.1:${server.address().port}`;
|
|
46
|
+
|
|
47
|
+
const lines = [];
|
|
48
|
+
const result = await uploadSourceMaps({ dir, release: "1.2.3", endpoint, apiKey: "ak_test", log: (l) => lines.push(l) });
|
|
49
|
+
server.close();
|
|
50
|
+
|
|
51
|
+
assert.equal(seen.length, 2);
|
|
52
|
+
assert.ok(seen.every((s) => s.url === "/api/sourcemaps/upload" && s.key === "ak_test"));
|
|
53
|
+
assert.ok(seen.some((s) => s.body.includes("~/assets/app-1.js") && s.body.includes("1.2.3")));
|
|
54
|
+
assert.deepEqual(result.uploaded, ["~/assets/app-1.js"]);
|
|
55
|
+
assert.equal(result.failed.length, 1);
|
|
56
|
+
assert.match(result.failed[0].error, /400 That is not a version 3 source map/);
|
|
57
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
test("dry run touches nothing, and misconfiguration fails clearly", async () => {
|
|
61
|
+
const dir = makeBuild();
|
|
62
|
+
const dry = await uploadSourceMaps({ dir, release: "1", dryRun: true });
|
|
63
|
+
assert.equal(dry.uploaded.length, 2);
|
|
64
|
+
await assert.rejects(uploadSourceMaps({ dir, release: "" }), /--release is required/);
|
|
65
|
+
await assert.rejects(uploadSourceMaps({ dir, release: "1", apiKey: "k", endpoint: "http://example.com" }), /https/);
|
|
66
|
+
await assert.rejects(uploadSourceMaps({ dir: os.tmpdir() + "/does-not-exist-midline", release: "1", apiKey: "k", endpoint: "https://api.example.com" }));
|
|
67
|
+
const empty = fs.mkdtempSync(path.join(os.tmpdir(), "midline-empty-"));
|
|
68
|
+
await assert.rejects(uploadSourceMaps({ dir: empty, release: "1", apiKey: "k", endpoint: "https://api.example.com" }), /No \.map files/);
|
|
69
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
70
|
+
fs.rmSync(empty, { recursive: true, force: true });
|
|
71
|
+
});
|