midline-agent
v0.7.0
Published
Midline — request, error and security monitoring for Node, and error, network, Web Vitals and pageview/visitor monitoring for browsers
Maintainers
Readme
midline-agent
SDK for Midline — request and error monitoring with security and threat detection.
It also ships midline-agent/browser for web apps: see Browser apps.
Midline itself is not tied to Node. Every event lands on the same stream through a plain JSON endpoint, so a Django, Rails, Laravel, Spring, Go or .NET service reports exactly what an Express service does. This package is the convenience wrapper for Node — if you are on another stack, skip to Any other backend.
How it fits together
There are two separate things, and the agent never confuses them:
| | What it is | Configured with |
| --- | --- | --- |
| Midline server (control plane) | Where events, API keys, logs, analytics and security data live. https://api.usemidline.com | endpoint / MIDLINE_ENDPOINT, apiKey / MIDLINE_API_KEY, ca / MIDLINE_CUSTOM_CA |
| Destination API (data plane) | The API being monitored — local, staging or production | target / TARGET_API_URL, targetCa / TARGET_API_CA (proxy mode only) |
Your traffic never goes through the Midline server. The agent runs next to your API, observes each request, and ships a redacted event to Midline in the background:
┌──────────────── your infrastructure ────────────────┐
client ────────► │ midline-agent ──────────────► destination API │
│ (middleware in your app, or proxy in front of it) │
└───────┬─────────────────────────────────────────────┘
│ events, batched, redacted, async (HTTPS, verified)
▼
https://api.usemidline.comThat is deliberate. If Midline is down, slow or misconfigured, your API keeps serving and only
telemetry is buffered. A hosted gateway that sits in the request path can't do that, and it can't
reach http://localhost:4000 on your laptop at all.
Two ways to run the agent:
- Middleware — inside an Express/NestJS/Node app. No extra hop.
- Proxy — a small reverse proxy in front of any HTTP API, in any language.
TARGET_API_URLdecides where requests go.
Install
npm install midline-agentGet a project API key from the Midline dashboard (Project → API Keys).
Middleware mode
Express
import express from "express";
import { MidlineAgent, midlineMiddleware, midlineErrorHandler } from "midline-agent";
MidlineAgent.init({
apiKey: process.env.MIDLINE_API_KEY,
serviceName: "checkout-api",
environment: process.env.NODE_ENV,
});
const app = express();
app.use(midlineMiddleware()); // before your routes
app.use(express.json());
app.post("/v1/charges", (req, res) => res.json({ ok: true }));
app.use(midlineErrorHandler()); // after your routes; passes the error on untouched
app.listen(3000);Behind a load balancer or CDN
By default the IP recorded for each request is Express's req.ip. Behind a load balancer that is the
load balancer's address, not your user's. Tell the middleware how many proxies sit in front of your server
and it reads the real address from X-Forwarded-For:
app.use(midlineMiddleware({ trustProxy: 1 })); // one load balancer or CDN in frontIt counts that many hops in from the end of the chain, so an address a client puts in the header
itself is ignored. Leave it unset if clients reach your server directly, or if you already set
app.set("trust proxy", …) yourself. trustProxy: true believes the whole header, which is only safe when
nothing can reach the server except through your proxies.
NestJS
import { NestFactory } from "@nestjs/core";
import { MidlineAgent, midlineMiddleware } from "midline-agent";
import { AppModule } from "./app.module";
async function bootstrap() {
MidlineAgent.init({
apiKey: process.env.MIDLINE_API_KEY,
serviceName: "nest-api",
environment: process.env.NODE_ENV,
});
const app = await NestFactory.create(AppModule);
app.use(midlineMiddleware());
app.enableShutdownHooks();
await app.listen(3000);
}
bootstrap();
// On shutdown (e.g. in onApplicationShutdown): await MidlineAgent.shutdown();Nest handles exceptions in its own filters, so they rarely reach Express error middleware. The request middleware still records every 4xx/5xx response, and Midline groups the 5xx responses into Issues by method, route and status.
Plain Node http
import http from "http";
import { MidlineAgent, midlineMiddleware } from "midline-agent";
MidlineAgent.init({ apiKey: process.env.MIDLINE_API_KEY, serviceName: "raw-node" });
const midline = midlineMiddleware();
http.createServer((req, res) => {
midline(req, res, () => {
res.writeHead(200, { "content-type": "application/json" });
res.end('{"ok":true}');
});
}).listen(3000);Proxy mode
Put the agent in front of any API — Node or not — without touching its code:
MIDLINE_API_KEY=... TARGET_API_URL=http://localhost:4000 npx midline-agent proxy --port 8080
# clients now call http://localhost:8080 instead of :4000The same binary, pointed elsewhere:
TARGET_API_URL=https://staging.example.com npx midline-agent proxy
TARGET_API_URL=https://api.example.com npx midline-agent proxy --host 0.0.0.0Or from code:
import { startMidlineProxy } from "midline-agent";
await startMidlineProxy({
target: process.env.TARGET_API_URL, // required; never defaulted
port: 8080,
midline: { apiKey: process.env.MIDLINE_API_KEY, serviceName: "orders-gateway" },
});createMidlineProxy(options) returns a plain (req, res) handler if you want your own server.
What the proxy does per request:
- Forwards method, path, query, headers and body to the target, streaming both ways
- Strips hop-by-hop headers; sets
Hostto the target (unlesspreserveHost), addsX-Forwarded-*,X-Request-IdandX-Correlation-Id - Only accepts origin-form request lines (
GET /path). It can't be used as an open forward proxy. - Answers
502if the destination is unreachable,504if it's too slow (timeoutMs, default 30s),413if the body exceedsmaxRequestBodyBytes(default 10 MiB). Error bodies carry arequestIdand no internal detail. - Retries (
retries, default 0) onlyGET/HEAD/OPTIONSwithout a body, and only when the request never reached the destination. A write is never replayed. - Records the exchange — including destination failures as
infrastructureerrors — to Midline
WebSocket upgrades are not proxied.
TLS
Certificate verification is always on, for both the Midline server and the destination.
There is no option to turn it off, and the agent never sets rejectUnauthorized: false.
The Midline server
https://api.usemidline.com has a publicly trusted certificate, so nothing needs configuring. If
verification ever fails, you'll see one line like:
midline: https://api.usemidline.com presented a self-signed certificate (DEPTH_ZERO_SELF_SIGNED_CERT),
so the connection was refused. Certificate verification stays on. ...
Delivery resumes on its own once a trusted certificate is served. 12 event(s) buffered; your application is unaffected.That is a server-side problem, not something to work around in your app. Events stay buffered and delivery resumes on its own once the server is fixed — no restart needed.
For a self-hosted Midline server behind a private CA, trust that CA for the Midline connection only:
MidlineAgent.init({ apiKey, endpoint: "https://midline.internal", ca: fs.readFileSync("/etc/ssl/internal-ca.pem") });MIDLINE_CUSTOM_CA=/etc/ssl/internal-ca.pem # PEM text also worksThe CA is added to Node's default trust store, not substituted for it. Hostname checks still apply.
endpoint must be https://. Plain http:// is only accepted for localhost/127.0.0.1 (running
a Midline server on your own machine), so the API key never crosses a network in cleartext.
The destination (proxy mode)
A local destination is fine over plain HTTP: TARGET_API_URL=http://localhost:4000. That doesn't
relax anything about the Midline connection.
For an HTTPS destination signed by a private/internal CA:
TARGET_API_URL=https://orders.internal TARGET_API_CA=/etc/ssl/internal-ca.pem npx midline-agent proxyTARGET_API_CA and MIDLINE_CUSTOM_CA are independent. Trusting a CA for one never trusts it for
the other.
Configuration
MidlineAgent.init({
apiKey: string, // MIDLINE_API_KEY
serviceName?: string, // MIDLINE_SERVICE_NAME
endpoint?: string, // MIDLINE_ENDPOINT — default https://api.usemidline.com (base or full ingest URL)
ca?: string | Buffer | Array, // MIDLINE_CUSTOM_CA — extra CA for the Midline server only
environment?: string, // MIDLINE_ENVIRONMENT
release?: string, // MIDLINE_RELEASE
host?: string,
region?: string,
enabled?: boolean, // MIDLINE_ENABLED=false keeps the agent inert
// What to capture beyond method, path, status and timing. All off by default.
capture?: {
headers?: boolean,
query?: boolean,
requestBody?: boolean, // whatever your body parser produced (req.body)
responseBody?: boolean,
maxBodyBytes?: number, // default 4096
},
redactFields?: string[], // added to the built-in list (maskFields still works)
redactHeaders?: string[],
captureConsole?: boolean, // MIDLINE_CAPTURE_CONSOLE — also send what the process prints (off by default)
captureIp?: boolean, // MIDLINE_CAPTURE_IP — record the caller's IP (on by default; false leaves it out)
// Delivery
flushIntervalMs?: number, // default 1500
timeoutMs?: number, // whole request deadline, default 10000
connectTimeoutMs?: number, // TCP + TLS handshake, default 5000
maxBatchSize?: number, // default 100
maxQueueSize?: number, // default 1000; oldest dropped past this
maxEventBytes?: number, // default 65536; bodies dropped first
maxRetryDelayMs?: number, // backoff cap, default 300000
onError?: (message: string) => void, // route diagnostics to your logger
debug?: boolean, // MIDLINE_DEBUG — log every diagnostic, not one per fault
});Environment
MIDLINE_API_KEY=...
MIDLINE_ENDPOINT=https://api.usemidline.com
# MIDLINE_CAPTURE_CONSOLE=true
# MIDLINE_CAPTURE_IP=false
# proxy mode
TARGET_API_URL=http://localhost:4000
# TARGET_API_CA=/etc/ssl/internal-ca.pem
# TARGET_API_TIMEOUT_MS=30000
# TARGET_API_RETRIES=0
# MIDLINE_PROXY_PORT=8080
# MIDLINE_PROXY_HOST=127.0.0.1Console output
Set captureConsole: true (or MIDLINE_CAPTURE_CONSOLE=true) and whatever the process prints
is sent as console events, one per line, and shows up on the dashboard's Terminal page. That
covers console.log, Nest's logger, pino, winston: anything written to stdout or stderr. The Logs page
stays request traffic.
MidlineAgent.init({ apiKey: process.env.MIDLINE_API_KEY, captureConsole: true });
const app = await NestFactory.create(AppModule); // startup lines are captured from here on- Initialise the agent before creating the app. Anything printed before
init()isn't captured. - Your terminal output doesn't change. The agent reads each write after it has happened.
- Colour codes are stripped, each line is capped at 4096 characters, and lines are redacted like any other text.
- Severity comes from the line:
ERRORorFATALis high,WARNis medium, other stderr output is medium, and everything else is low. - Up to 100 lines a second are sent, with room for a 1,000-line burst such as Nest mapping its routes at startup. Lines past that still print but aren't sent.
- Printed lines don't count towards request totals, error rates, latency or Issues.
- It needs a Midline server that knows the
consoleevent type. An older server refuses the first line; the agent then turns console capture off, says so once, and keeps sending requests and errors. - The agent's own diagnostics are never captured.
IP addresses
Request events carry the address of whoever made the request. It shows in the dashboard as the IP address column on Logs and on the Visitors page's Recent visitors table. Where it comes from depends on how you connect:
| You use | Address recorded |
| --- | --- |
| Express or NestJS middleware | Express's req.ip. With trustProxy, the client read from X-Forwarded-For. |
| Plain Node http middleware | The socket's peer. With trustProxy, the client read from X-Forwarded-For. |
| Proxy mode | The socket's peer. With trustForwardedHeaders, the first X-Forwarded-For entry. |
| Browser SDK | The address the Midline server saw the browser connect from. Nothing to configure, and any IP the page sends is ignored. |
- Behind a load balancer or CDN, set
trustProxy(see Express) or every request shows the proxy's address instead of your user's. - On your own machine every caller is
::1or127.0.0.1: the address a computer uses for itself. Real addresses appear once real traffic reaches the service. - A self-hosted Midline server that sits behind a proxy needs
TRUST_PROXY=<number of proxies>in its own environment, so browser events record the visitor rather than the proxy. Left unset,X-Forwarded-Foris ignored, which stops a client from choosing the address that is recorded.
Not recording IP addresses
An IP address can count as personal data. To keep it out entirely:
MidlineAgent.init({ apiKey: process.env.MIDLINE_API_KEY, captureIp: false }); // or MIDLINE_CAPTURE_IP=falseMidline.init({ apiKey: "pk_…", captureIp: false }); // browser SDKThe Node agent drops the address in your process, so it is never sent. The browser SDK can only ask: the address is read by the Midline server, which then doesn't store it. A server that predates 0.7.0 ignores the request, so update a self-hosted server before relying on it. Events without an address show "—" in the dashboard.
Sensitive data
Redaction happens in your process, before an event is queued. What's masked never reaches a socket, a log line or the Midline server. The server redacts again on ingest as a second line of defence.
- Headers, always masked when captured:
Authorization,Proxy-Authorization,Cookie,Set-Cookie,X-API-Key,API-Key,X-Auth-Token,X-Access-Token,X-Refresh-Token,X-CSRF-Token,X-XSRF-Token,X-Amz-Security-Token, plus anything matching the field rules below. - Fields, at any depth, in bodies, queries and payloads. Matched case- and
punctuation-insensitively, so
api_key,apiKeyandX-API-Keyare the same key. Anything containingpassword,passwd,passphrase,secret,token,apikey,accesskey,privatekey,authorization,cookie,session,credential,csrf,xsrf,signature,creditcard,cardnumber,cvv,cvc,ssn,socialsecurity; and the exact keysauth,pwd,pin,otp,sid,jwt,bearer. - Values inside any text — error messages, stack traces, routes, console lines: bearer/basic credentials,
JWTs, Midline keys (
ak_…), Stripe and AWS key formats, PEM private keys,user:pass@in URLs, andkey=value/"key": valuepairs whose key is sensitive. - Query strings are never part of
route. - Bodies are only captured if you turn them on. They're capped at
maxBodyBytes, and compressed or binary bodies are skipped.
The lists err on the side of masking (tokenCount is masked too). Extend them with redactFields
and redactHeaders.
Delivery and failure behaviour
The agent's contract is that the Midline server can never break your application.
- Recording happens after the response is handed to the socket; nothing waits on the network
- Events are batched (
POST /api/api-monitor/events/batch) with the key in theX-API-Keyheader - Unreachable, DNS failure, timeout, TLS failure, 5xx, 408, 429: events stay buffered.
Retries use exponential backoff with jitter, capped by
maxRetryDelayMs, and honourRetry-After. - 401/403: the key is wrong. The agent logs once and turns itself off — retrying can't fix it.
- 413: the batch is split and retried; a single event that's still too large is dropped
- Other 4xx: the batch is re-sent one event at a time, so a malformed event only costs itself
- Redirects are never followed, because that would hand the API key to wherever they point
- One log line per distinct fault, and one when delivery recovers
- The buffer is bounded (
maxQueueSize, 16 MB total); past that, the oldest events go first - The flush timer and background sockets are
unref'd, so telemetry never keeps your process alive
Events still in memory are lost if the process crashes. That's fine for operational telemetry, but not for billing or audit. On graceful shutdown:
process.on("SIGTERM", async () => {
await MidlineAgent.shutdown(5000); // flush with a deadline, then close
process.exit(0);
});Request and correlation IDs
Every request gets a requestId: an incoming X-Request-Id if it's sane, otherwise a UUID. It also
gets a correlationId: X-Correlation-Id if present, otherwise the request ID. A W3C
traceparent header becomes traceId/spanId. Read them in your own code to stamp logs:
import { getRequestContext } from "midline-agent";
app.get("/x", (req, res) => { logger.info({ requestId: getRequestContext(req)?.requestId }); });Browser apps
midline-agent/browser monitors a web app from the page: uncaught errors and unhandled rejections,
failed fetch and XMLHttpRequest calls, Web Vitals (LCP, INP, CLS, FCP, TTFB), pageviews for
visitor counting and funnel views, and, if you ask, console output. It has no dependencies, no Node
code, and works under React, Vue, Angular, Svelte, Next.js or no framework at all, because it
instruments the page rather than a framework.
import * as Midline from "midline-agent/browser";
Midline.init({
apiKey: import.meta.env.VITE_MIDLINE_BROWSER_KEY, // pk_… browser key
service: "checkout-web",
environment: import.meta.env.MODE,
release: import.meta.env.VITE_RELEASE,
});Use a browser key, never a server key. Anything in a bundle can be read by whoever loads the page,
so browser keys (pk_…) are public by design and the Midline server accepts them only from the
origins listed on the key (create one under Project → API Keys → Browser). Server keys (ak_…)
are refused whenever a browser sends them, and the SDK won't start with one.
Errors a framework catches itself never reach the window. Forward them:
// React error boundary
componentDidCatch(error: Error, info: ErrorInfo) {
Midline.captureException(error, { extra: { componentStack: info.componentStack } });
}
// Vue
app.config.errorHandler = (error, _instance, info) => Midline.captureException(error, { extra: { info } });Linking a page to your backend. Same-origin requests get a W3C traceparent header, which the
Node agent on the backend turns into traceId/spanId (see above), so the page's failed call and
the server request behind it share a trace id. For an API on another origin, list it in
tracePropagationTargets and allow the traceparent header in that API's CORS configuration, or
the browser will block the call's preflight.
| Option | Default | |
| --- | --- | --- |
| apiKey | — | Browser key (pk_…). |
| endpoint | https://api.usemidline.com | http:// only for localhost. |
| service, environment, release | — | Stamped on every event. |
| captureErrors | true | Uncaught errors and unhandled rejections. |
| captureRequests | "failed" | "failed" (4xx, 5xx, network errors), "all", or false. Every call is a breadcrumb either way. |
| captureWebVitals | true | Reported once, when the page is first hidden. |
| 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. |
| 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). |
| 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. |
| tracePropagationTargets | same origin | Strings match as URL prefixes (or path prefixes starting with /); RegExps match the full URL. |
| ignoreErrors, ignoreUrls | [] | Strings match as substrings. |
| sampleRate | 1 | Fraction of events sent. |
| maxEventsPerMinute | 120 | So an error in a render loop can't flood the project. |
| beforeSend | — | Return null to drop an event; edit payload and metadata freely. |
| redactFields | — | Extra field names to redact. |
| enabled, debug | true, false | |
Also: captureMessage(message, severity), setUser({ id }), setTag(key, value),
addBreadcrumb(message), flush() and close(), which restores everything the SDK wrapped.
What it will not do: it never sends cookies, query strings of the page URL, or request and response
bodies; it redacts the same credential patterns as the Node agent before an event is queued; it
never throws into your code; and if Midline is down it keeps a bounded queue and backs off. Pending
events leave with keepalive when the tab is hidden. During server-side rendering, init does
nothing.
No bundler? Load the ES module build directly:
<script type="module">
import * as Midline from "https://cdn.jsdelivr.net/npm/[email protected]/dist/esm/browser/index.js";
Midline.init({ apiKey: "pk_…", service: "web" });
</script>The Midline server must be recent enough to accept browser keys (it answers the CORS preflight on
the ingest routes). Against an older server the SDK logs nothing unless debug is on and keeps
retrying with backoff.
Any other backend
The agent does two things: it turns a request or an error into an event, and it POSTs that event to
the Midline ingest API. Your own service can do both directly, in whatever language it's written in
— or run midline-agent proxy in front of it.
Endpoints
| Endpoint | Use |
| --- | --- |
| POST https://api.usemidline.com/api/api-monitor/events | One event |
| POST https://api.usemidline.com/api/api-monitor/events/batch | Many events — { "events": [ … ] }, up to 500 |
Authenticate with the X-API-Key header. An apiKey field in the body is still accepted.
Event shape
| Field | Required | Notes |
| --- | --- | --- |
| eventType | yes | request · error · security · performance · pageview · custom |
| route | yes | Path only, e.g. /v1/charges. For pageview, the page path. |
| method | no | HTTP method |
| statusCode | no | 100–599 |
| responseTime | no | Milliseconds |
| severity | no | low · medium · high · critical |
| category | no | application · infrastructure · security · performance · business |
| service / environment / release | no | Where it came from |
| timestamp | no | ISO 8601; clamped to server time if implausibly far off |
| requestId / correlationId / traceId | no | For joining events across services |
| sessionId | no | A stable id for one visitor's session (e.g. a browser tab). Send the same value on every event from that session and Midline can build visitor counts and funnels from pageview events. Keep it opaque — never an email or a real user id. |
| payload | no | Free-form context — error message, stack, captured request/response |
| metadata | no | Free-form, e.g. SDK name and version |
Mask sensitive fields in your own process before you build the payload. Nothing you don't send can be stored.
curl -X POST https://api.usemidline.com/api/api-monitor/events \
-H "Content-Type: application/json" \
-H "X-API-Key: $MIDLINE_API_KEY" \
-d '{
"eventType": "error",
"service": "checkout-api",
"route": "/v1/charges",
"method": "POST",
"statusCode": 500,
"responseTime": 1240,
"severity": "critical",
"category": "application",
"payload": { "error": "Database connection failed" }
}'import os, threading, requests
INGEST = "https://api.usemidline.com/api/api-monitor/events"
def report(event: dict) -> None:
# off the request path - never make a user wait on telemetry
threading.Thread(
target=lambda: requests.post(INGEST, json=event, headers={"X-API-Key": os.environ["MIDLINE_API_KEY"]}, timeout=3),
daemon=True,
).start()func Report(body []byte) {
go func() {
req, _ := http.NewRequest("POST", "https://api.usemidline.com/api/api-monitor/events", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("X-API-Key", os.Getenv("MIDLINE_API_KEY"))
client := &http.Client{Timeout: 3 * time.Second}
if resp, err := client.Do(req); err == nil {
resp.Body.Close()
}
}()
}Same rule everywhere: send events from a goroutine, a background thread or a queue worker. Keep TLS verification on. Telemetry should never be able to slow down or fail a response.
Upgrading from 0.1.x
insecureTLS/MIDLINE_INSECURE_TLSare gone (they were never published). Fix the server's certificate, or pass the private CA withca/MIDLINE_CUSTOM_CA.endpointcan now be justhttps://api.usemidline.com; full ingest URLs still work.maskFieldsused to be accepted and ignored. It's now honoured, as an alias forredactFields.serviceNameis optional;MIDLINE_API_KEY/MIDLINE_ENDPOINTare read if not passed.cross-fetchis no longer a dependency; Node 18+ is required.- New:
MidlineAgent.shutdown(timeoutMs)flushes with a deadline and stops the agent. - The agent sends the key both in the
X-API-Keyheader and in each event body. That keeps it compatible with Midline servers that predate header auth.
License
MIT
