midline-agent 0.6.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 +6 -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/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/types.d.ts +6 -0
- package/package.json +1 -1
- 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/client-ip.ts +38 -0
- package/src/config.ts +2 -0
- package/src/middleware.ts +10 -1
- package/src/types.ts +6 -0
- package/test/browser.test.js +13 -0
- package/test/middleware.test.js +72 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,12 @@
|
|
|
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
|
+
|
|
5
11
|
## 2026-09-20
|
|
6
12
|
- feat: `midline-agent sourcemaps upload <dir> --release <name>` uploads a build's source maps to Midline
|
|
7
13
|
so stack traces show original code. Authenticates with a project server API key; browser keys are refused
|
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";
|
|
@@ -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,
|
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,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "midline-agent",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
4
4
|
"description": "Midline \u2014 request, error and security monitoring for Node, and error, network, Web Vitals and pageview/visitor monitoring for browsers",
|
|
5
5
|
"homepage": "https://usemidline.com",
|
|
6
6
|
"main": "dist/index.js",
|
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/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,
|
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
|
+
});
|