midline-agent
v0.8.0
Published
Midline — request, error and security monitoring for Node, and error, network, Web Vitals and pageview/visitor monitoring and session replay 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 MidlineModule once. It starts the agent, records every request, reports the exceptions
Nest's own filters would otherwise swallow, and flushes on shutdown:
import { Module } from "@nestjs/common";
import { MidlineModule } from "midline-agent/nest";
@Module({
imports: [
MidlineModule.forRoot({
apiKey: process.env.MIDLINE_API_KEY,
serviceName: "nest-api",
environment: process.env.NODE_ENV,
}),
],
})
export class AppModule {}Add app.enableShutdownHooks() in main.ts so queued events are sent on SIGTERM.
- The agent starts when
AppModuleis loaded, beforeNestFactory.create, so bootstrap output is captured. Ifmain.tsalready calledMidlineAgent.init(), that agent is used instead. - Exceptions are reported when they answer 500 or above. Nest's response is unchanged: the filter
reports, then hands over to Nest's default filter. Your own
@Catch()filters still run first for what they catch.reportClientErrors: truereports 4xxHttpExceptions too. middleware: falseif you alreadyapp.use(midlineMiddleware()); pass middleware options instead oftrueto tune capture.- Microservice and websocket contexts aren't reported; their exceptions pass through untouched.
Without the module, app.use(midlineMiddleware()) in main.ts still records every request, and
Midline groups 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.
Activity log
Record what people do in your product (an invoice paid, a member invited, a setting changed) and search it on the dashboard's Activity page. One call per action, from wherever the action happens:
import { trackActivity } from "midline-agent";
app.post("/invoices/:id/pay", async (req, res) => {
const invoice = await payInvoice(req.params.id);
trackActivity("invoice.paid", {
actor: { id: req.user.id, name: req.user.name, email: req.user.email },
target: { type: "invoice", id: invoice.id, name: invoice.number },
metadata: { amount: invoice.total, currency: invoice.currency },
req, // optional: adds the user's IP, user agent and this request's ids
});
res.json(invoice);
});| Option | Required | Notes |
| --- | --- | --- |
| actor | yes | Who did it: { id, type?, name?, email? }, or just an id. type defaults to "user"; use "api_key", "system" and so on for anything else. |
| target / targets | no | What it was done to: { type, id, name? }. Up to 20. |
| description | no | A sentence for the dashboard, e.g. "Ada paid INV-104". |
| metadata | no | Any extra detail. Redacted like everything else the agent sends. |
| req | no | The incoming request. With midlineMiddleware, the IP follows its trustProxy setting. |
| timestamp | no | When it happened. Defaults to now. |
- Name actions
noun.verbin the past tense, such asinvoice.paidormember.role_changed: 1–128 letters, digits and. _ : / -. The Activity page groups and filters by action. trackActivityuses the agent fromMidlineAgent.init(); with your own agent, callagent.activity(action, options). Beforeinit()it does nothing.- It never throws. A malformed call (no actor, a bad action name) is reported once through
onErrorand skipped. - Activities are queued, batched and retried with your other events, and don't count towards request totals, error rates or Issues.
- Server keys only. A browser key is public, so anyone could forge entries with it, and the Midline server refuses activities sent with one.
captureIp: falseleaves the IP out of activities too.
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). |
| replay | — | Session replay: replayIntegration() from midline-agent/browser/replay. See below. |
| 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.
Session replay
A recording of what the visitor saw and did, played back on the dashboard's Replays page next to the errors and failed requests along the way. It is a separate entry point, so the recorder (rrweb, about 30 KB gzipped) only ships in apps that import it:
import * as Midline from "midline-agent/browser";
import { replayIntegration } from "midline-agent/browser/replay";
Midline.init({
apiKey: "pk_…",
replay: replayIntegration({
sessionSampleRate: 0.1, // record and upload 1 session in 10 from start to finish
errorSampleRate: 1, // for the rest, upload the last 1-2 minutes when an error happens
}),
});- Two modes. A sampled session uploads a segment every 5 seconds. Every other session records into memory only, keeping the last one to two minutes; an error decides whether that buffer is uploaded, and from then on it records like a sampled session. Nothing leaves the page otherwise.
- Linked to errors. Events sent while a replay is uploading carry
metadata.replayId, so an issue's occurrence links to the recording at the moment of the error. The SDK's breadcrumbs (navigation, requests, console lines) are recorded into the replay as timeline markers. - Masked by default, in the browser. All text (including placeholders, titles, alt text and ARIA
labels) becomes
*, inputs are masked, and images, video, SVG, canvas and iframes are recorded as empty boxes.class="midline-unmask"records an element's text as it is,midline-maskhides it whenmaskAllTextis off, andmidline-blockleaves an element out. Passwords are always masked. - Bounded. One replay records at most 60 minutes; after 15 idle minutes the next interaction
starts a new one. A reload in the same tab continues the current replay. Uploads are gzipped with
CompressionStream, retried with backoff, and stop for good if the server answers 401, 403 or 409.
| replayIntegration option | Default | |
| --- | --- | --- |
| sessionSampleRate | 0.1 | Fraction of sessions recorded and uploaded throughout. |
| errorSampleRate | 1 | Of the rest, fraction uploaded when an error happens. 0 records sampled sessions only. |
| maskAllText, maskAllInputs, blockAllMedia | true | |
| mask, unmask, block | [] | Extra CSS selectors, on top of the midline-* classes. |
| flushIntervalMs | 5000 | How often a recording session uploads. |
| idleTimeoutMs | 15 minutes | Inactivity that ends a replay. |
| maxDurationMs | 60 minutes | Longest single replay (also the maximum). |
Replays need a Midline server with the /api/api-monitor/replays route. They are kept for the same
time as the project's events.
React
midline-agent/react is the browser SDK plus an error boundary, because errors React catches never
reach window.onerror:
import * as Midline from "midline-agent/react";
Midline.init({ apiKey: import.meta.env.VITE_MIDLINE_BROWSER_KEY, service: "web" });
createRoot(document.getElementById("root")!).render(
<Midline.ErrorBoundary fallback={({ resetError }) => <button onClick={resetError}>Try again</button>}>
<App />
</Midline.ErrorBoundary>,
);fallback is an element or a function of { error, resetError }; onError and tags are optional.
On React 19, createRoot(el, { onUncaughtError: Midline.reactErrorHandler() }) also reports errors
outside any boundary.
Vue
import * as Midline from "midline-agent/vue";
Midline.init({ apiKey: import.meta.env.VITE_MIDLINE_BROWSER_KEY, service: "web" });
createApp(App).use(Midline.MidlineVue).mount("#app");The plugin sets app.config.errorHandler. A handler you set before it still runs; without one, errors
are logged to the console as Vue would.
Next.js
Three files. The browser SDK in instrumentation-client.ts (Next 15.3+):
import * as Midline from "midline-agent/react";
Midline.init({
apiKey: process.env.NEXT_PUBLIC_MIDLINE_BROWSER_KEY!,
service: "web",
release: process.env.NEXT_PUBLIC_MIDLINE_RELEASE,
});Server errors in instrumentation.ts (Next 15+). onRequestError covers rendering, route handlers
and server actions:
export async function register() {
if (process.env.NEXT_RUNTIME === "nodejs") {
const { MidlineAgent } = await import("midline-agent");
MidlineAgent.init({ serviceName: "web" }); // MIDLINE_API_KEY from the environment
}
}
export { onRequestError } from "midline-agent/next";Source maps in next.config.ts:
import { withMidline } from "midline-agent/next/config";
export default withMidline({ /* your config */ });On next build with MIDLINE_API_KEY set (a server key), withMidline turns on browser source
maps, uploads them once compiled, and deletes them from .next/static so visitors can't fetch your
source. The release is MIDLINE_RELEASE, else VERCEL_GIT_COMMIT_SHA, else the git commit; it's exposed
as NEXT_PUBLIC_MIDLINE_RELEASE. Without a key, or on Next before 15.4.1, the build is unchanged
(use midline-agent sourcemaps upload in CI). Upload problems are logged, never fail the build. It
also adds midline-agent to serverExternalPackages.
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 · activity · custom |
| route | yes | Path only, e.g. /v1/charges. For pageview, the page path. For activity, the action, e.g. invoice.paid. |
| 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. For activity, { actor: { id, type?, name?, email? }, targets?: [{ type, id, name? }], description?, metadata? }; actor.id is required. |
| 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()
}
}()
}An activity from any backend is the same POST (server key only):
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": "activity",
"route": "invoice.paid",
"payload": {
"actor": { "id": "usr_42", "name": "Ada Lovelace", "email": "[email protected]" },
"targets": [{ "type": "invoice", "id": "inv_104", "name": "INV-104" }],
"metadata": { "amount": 4900, "currency": "USD" }
}
}'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
