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 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),
@@ -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)
@@ -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.6.0";
2
+ export declare const BROWSER_SDK_VERSION = "0.7.0";
@@ -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.6.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
@@ -27,6 +27,7 @@ export interface ResolvedConfig {
27
27
  redactHeaders: string[];
28
28
  capture: ResolvedCapture;
29
29
  captureConsole: boolean;
30
+ captureIp: boolean;
30
31
  onError?: (message: string) => void;
31
32
  debug: boolean;
32
33
  flushIntervalMs: number;
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.6.0";
2
+ export const BROWSER_SDK_VERSION = "0.7.0";
@@ -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.
@@ -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.ip ?? req.socket?.remoteAddress,
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.6.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),
@@ -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;
@@ -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
@@ -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.6.0";
2
+ export const BROWSER_SDK_VERSION = "0.7.0";
@@ -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.ip ?? req.socket?.remoteAddress,
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;
@@ -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 });
@@ -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
+ });