@lmstech/monitor
v0.3.0
Published
Lightweight monitoring package for Node apps. Captures server logs, client errors, uncaught exceptions, and unhandled rejections — sends everything to the Observatory dashboard.
Readme
@lmstech/monitor
Lightweight monitoring package for Node apps. Captures server logs, client errors, uncaught exceptions, and unhandled rejections — sends everything to the Observatory dashboard.
If MONITOR_URL and MONITOR_KEY aren't set, the entire package is inert. No patching, no listeners, no network calls.
Four entry points:
| Entry | For |
|---|---|
| @lmstech/monitor/server | Next.js apps (App Router) |
| @lmstech/monitor/node | Any plain Node process — Express, Fastify, a bare http server, a worker/CLI |
| @lmstech/monitor/express | Express apps specifically — error middleware + a mountable proxy endpoint |
| @lmstech/monitor/client | Browser — React provider + hook (works in Next.js or a plain Vite/React SPA) |
/server, /node, and /express all share the same underlying init/log/batch-queue core — there is no Next.js or React anywhere on the /node or /express import paths. Calling init() via @lmstech/monitor/node (or /server) arms monitorErrorHandler() from @lmstech/monitor/express in the same process — they're not independent copies. This is a real singleton per module system: the package's internal core modules are built unbundled (see tsup.config.ts), so every entry point that imports them resolves to the exact same file on disk, and Node's own module cache does the sharing.
Limitation — don't mix require() and import() of this package in one process. Node treats CommonJS and ESM as two separate module registries, so a process that both require("@lmstech/monitor/node") and import("@lmstech/monitor/express") would load two independent copies of the core (one per registry) — init() in one would not arm the middleware loaded via the other. This is the general "dual package hazard" any dual-format npm package has, not something specific to @lmstech/monitor; pick one module system per process (which is already the normal case for any single Node app) and it doesn't apply.
Setup in a Next.js app
Four files, nine lines.
1. instrumentation.ts (project root)
export async function register() {
const { init } = await import("@lmstech/monitor/server");
init({ app: "my-app", env: "production", version: process.env.COMMIT_SHA });
}Filtering noisy events
Pass an ignore predicate to drop events before they're reported — useful for noisy-but-harmless errors like HTTP socket aborts from client disconnections:
init({
app: "my-app",
env: "production",
ignore: (event) =>
event.message === "aborted" && event.stack?.includes("abortIncoming") === true,
});The predicate runs for every event (console.error, uncaughtException, unhandledRejection, log). Return true to drop. A throwing predicate is treated as "do not ignore" — a broken filter won't hide errors.
2. app/api/monitor/route.ts
export { POST } from "@lmstech/monitor/server";3. app/layout.tsx
import { Monitor } from "@lmstech/monitor/client";
export default function RootLayout({ children }) {
return (
<html>
<body>
<Monitor app="my-app" env="production">
{children}
</Monitor>
</body>
</html>
);
}4. Environment variables
MONITOR_URL=https://your-dashboard.vercel.app
MONITOR_KEY=your-api-key
COMMIT_SHA=abc123 # typically set by CI (GITHUB_SHA in GitHub Actions)Don't set these in local dev. The package stays invisible when they're absent.
Setup in a plain Node app (no Express)
One line.
import { init } from "@lmstech/monitor/node";
init({ app: "my-worker", env: "production", version: process.env.COMMIT_SHA });Call it once, as early as possible in your process's startup. It patches console.error, registers uncaughtException/unhandledRejection handlers, and starts the batch flush timer — same behavior as @lmstech/monitor/server, minus anything Next.js-specific. Use log() for custom events the same way as the server entry (see below).
Setup in an Express app
Three pieces: init, an error middleware, and (if you're also using the browser <Monitor> client) a proxy endpoint.
1. Init — early in your server's startup
import { init } from "@lmstech/monitor/node";
init({ app: "my-api", env: "production", version: process.env.COMMIT_SHA });2. Error-reporting middleware — mounted LAST, after all routes
import { monitorErrorHandler } from "@lmstech/monitor/express";
app.use(monitorErrorHandler());Reports the error with request method + route path only. Never the request body, headers, cookies, or query string — those can carry PHI, auth tokens, or session data, and are never read or serialized by this middleware. Always calls next(err) afterward, so your app's own error handling is unaffected whether reporting succeeds, fails, or is a no-op (e.g. init() was never called).
3. Client proxy endpoint — only needed if you're also mounting <Monitor> in a browser client
import { monitorRequestHandler } from "@lmstech/monitor/express";
app.post("/api/monitor", monitorRequestHandler());This is the Express equivalent of the Next.js POST route re-export — it validates and normalizes the event the browser client posted, then forwards it to the dashboard server-side with MONITOR_KEY attached (the key never reaches the browser). Works with or without a body-parser mounted ahead of it; if none is mounted for this route, it reads the raw request stream itself. Always responds 200.
4. Environment variables
Same as the Next.js setup — MONITOR_URL, MONITOR_KEY, optionally COMMIT_SHA. Unset in local dev; the package stays inert.
Custom logging
Server-side (Next.js, plain Node, or Express)
import { log } from "@lmstech/monitor/server"; // or "@lmstech/monitor/node"
log({ level: "info", message: "Payment processed", meta: { userId: "123" } });Client-side
import { useMonitor } from "@lmstech/monitor/client";
const { log } = useMonitor();
log({ level: "error", message: "Checkout failed", meta: { step: "payment" } });useMonitor() must be called inside the <Monitor> provider tree. The <Monitor>/useMonitor() client works unchanged in a plain Vite + React SPA — it isn't Next.js-specific. It posts events to /api/monitor on the same origin, so mount monitorRequestHandler() (Express) or the re-exported POST (Next.js) at that path.
Developing the package
Everything runs from the repo root.
pnpm pkg:build # clean build (tsup)
pnpm pkg:release # validate + build + version-bump + publish to npmThe build uses tsup (see tsup.config.ts) to emit both ESM and CJS, plus matching .d.ts/.d.cts, for every entry — so require("@lmstech/monitor/...") and import("@lmstech/monitor/...") both resolve to real files. Two sub-configs: server/node/express build unbundled (bundle: false) so core/, shared/, and types.ts compile to real, individually-shared files instead of being duplicated into each entry's bundle — see the "shared runtime state" note above. client stays a single self-contained bundle (it never touches the server-side core) and its own tsup sub-config forces its output to start with "use client" without affecting the other entries.
Testing
cd packages/monitor
pnpm test # run tests once
pnpm test:watch # watch mode
pnpm typecheck # type-check without emittingPackage structure
src/
types.ts # all public types (EventLevel, LogPayload, InitOptions, etc.)
core/ # framework-agnostic core — no Next.js or React imports
init.ts # console.error patching, process handlers, startup event
log.ts # custom server-side log()
batch.ts # in-memory queue, 5s / 50-event flush
send.ts # fire-and-forget fetch to /api/ingest
proxy.ts # validate/normalize/forward a browser-origin event (shared by server + express)
server/
index.ts # exports: init, log, POST — re-exports core + adds the Next.js route
route.ts # Next.js App Router POST handler for the client-side proxy
node/
index.ts # exports: init, log — plain re-export of core, for any non-Next Node app
express/
index.ts # exports: monitorErrorHandler, monitorRequestHandler
middleware.ts # (err, req, res, next) error-reporting middleware — method/route meta only
handler.ts # mountable POST handler for the client-side proxy (duck-typed vs. Express, no express dependency)
client/
index.ts # exports: Monitor, useMonitor
context.tsx # React context for app/env
monitor.tsx # error + unhandledrejection listeners, sendBeacon
use-monitor.ts # hook for custom client-side logging
shared/
serialize.ts # safe meta serialization (circular refs, depth/size limits)
limits.ts # MAX_MESSAGE_LENGTH, shared across every reporting path
url.ts # stripQueryString — never report a URL's query stringcore/ and shared/ (plus types.ts) are built unbundled — each file compiles 1:1 to its own file under dist/, so every entry point (server, node, express) that imports them shares the exact same module instance at runtime instead of getting its own inlined copy. See the "shared runtime state" note above, and the long comment at the top of tsup.config.ts, for why this matters.
Four entry points: @lmstech/monitor/server, @lmstech/monitor/node, @lmstech/monitor/express, and @lmstech/monitor/client.
Publishing
- Bump the version in
packages/monitor/package.json. - From the repo root:
pnpm pkg:publish.
That's it. The script builds first, then publishes dist/ to npm.
First time only: make sure you're logged in (npm login) and the @lmstech npm org exists with public access. The publish script uses --access public for the scoped package.
