Log Portal
A drop-in logging package for Express + TypeScript backends. It writes structured logs to the filesystem as JSONL and exposes a read API (historical + live SSE) so this dashboard can show your server logs to any developer — no SSH or server access needed. Zero new npm dependencies (node built-ins + the express you already have).
Overview
You mount the package once in your Express app. From then on it captures logs, persists them, and serves them at a base path (default /api/_logs). Point this dashboard at your backend URL and you can read history and tail live logs in the browser.
Your Express app ──▶ mountLogPortal(app)
│ writes <cwd>/logs/YYYY-MM-DD.jsonl
└ serves GET /api/_logs (history, newest-first)
GET /api/_logs/stream (live SSE tail)
Dashboard ──▶ reads those endpoints over your backend URL1. Install
Install straight from GitHub (the package builds itself on install):
npm install github:achego/log-portal
# or pin a tag/commit:
npm install github:achego/log-portal#v1.0.0express is a peer dependency (you already have it). winston-transport is an optional peer — install it only if you use the winston transport.
2. Mount it
Mount it once, after your body parsers:
import { mountLogPortal } from "log-portal";
mountLogPortal(app); // mounts the read API and starts capturing logsThat's it. By default it captures all console.* output, writes to <cwd>/logs/YYYY-MM-DD.jsonl, and serves the API at /api/_logs. Options passed to mountLogPortal(app, {...}) always win over env vars.
3. Run locally (test with a frontend)
The package is a library — to exercise it (or point this dashboard at it) you mount it in an Express server. A ready-to-run example lives in example/server.ts; it adds CORS, demo routes, and a heartbeat log so the live stream always has data:
npm install # installs dev deps (tsx, express, …)
cp .env.example .env # optional — set PORT / LOG_PORTAL_* overrides
npm run example # starts http://localhost:4000
# or: npm run example:watch to reload on editsThe package itself never loads a .env — mountLogPortal just reads process.env.LOG_PORTAL_* at startup. In your own backend, use whatever already populates process.env(dotenv, your platform's env config, etc.). Then point a frontend at the read API:
| URL | Use |
|---|---|
| http://localhost:4000/api/_logs | historical logs (newest first) |
| http://localhost:4000/api/_logs/stream | live SSE stream |
| http://localhost:4000/api/_logs/health | quick sanity check |
Generate some logs by hitting /demo/info and /demo/error on the example server.
4. Configuration
Options passed to mountLogPortal(app, options) take precedence over env vars, which fall back to defaults:
| Option | Env var | Default |
|---|---|---|
| basePath | LOG_PORTAL_BASE_PATH | /api/_logs |
| logsDir | LOG_PORTAL_DIR | <cwd>/logs |
| retentionDays | LOG_PORTAL_RETENTION_DAYS | -1 (off) |
| maxFileMb | LOG_PORTAL_MAX_FILE_MB | 0 (off) |
| flushIntervalMs | LOG_PORTAL_FLUSH_MS | 0 (sync) |
| maxBufferLines | LOG_PORTAL_BUFFER_LINES | 1000 |
| captureConsole | LOG_PORTAL_CAPTURE_CONSOLE | true |
| logRequests | LOG_PORTAL_LOG_REQUESTS | true |
| detectThreats | LOG_PORTAL_DETECT_THREATS | true |
| maxUrlLength | LOG_PORTAL_MAX_URL_LEN | 2000 |
| enabled | LOG_PORTAL_ENABLED | true |
Set enabled: false (or LOG_PORTAL_ENABLED=false) to disable entirely. Built-in auth has its own options — see Auth.
5. Storage: rotation, size cap & retention
Logs are written as YYYY-MM-DD.jsonl, rotating daily. Size-based rolling and automatic deletion are both off by default — nothing is rolled or removed unless you opt in, so you keep full history until you decide otherwise (manage it via the admin endpoints).
- Set
maxFileMb(e.g.50) to roll a busy day into segments (YYYY-MM-DD.1.jsonl, …) so no single file grows unbounded. Default0= no rolling. - Set
retentionDays(e.g.7) to auto-delete files older than that many days. Default-1= keep forever.
6. Write durability vs throughput
By default each log is written synchronously (fs.appendFileSync) — durable, but a blocking disk write per log. Under heavy load set flushIntervalMs (e.g. 500) to batch writes in memory and flush once per interval (or once maxBufferLines is buffered). Live SSE is unaffected — entries are pushed the instant they're logged. The buffer is flushed on a timer, on size threshold, on rotation, before every read, and on process exit. The trade-off: a hard kill -9 / power loss can drop up to one flush window. Sync mode (the default) has no such window.
7. Threat detection
When detectThreats is on, a middleware flags obviously malicious requests and emits them at level: "threat" (detection-only — it never blocks). Built-in stateless rules: path traversal, sensitive-path probes (/.env, /.git, …), scanner user-agents (sqlmap, nikto, nmap, …), tight SQLi/XSS patterns, and oversized URLs. Detection runs on the URL, query string and user-agent only — bodies aren't inspected. Add your own rule:
import { registerThreatRule } from "log-portal";
registerThreatRule({
type: "graphql_introspection",
severity: "low",
test: (ctx) => (ctx.decodedUrl.includes("__schema") ? "__schema" : null),
});8. Logging from your code
console.log/info/warn/error/dir are captured automatically. For richer entries use the explicit API:
import { logger } from "log-portal";
logger.info("user created", { userId });
logger.error("payment failed", err); // pass the error as the 2nd arg
logger.error(err); // or just the error itself
logger.error("charge failed", err, { orderId }); // message + error + extra metalogger.error / logger.warn extract type-specific detail into a single consistent err shape ({ name, message, stack, code, data }) so the dashboard renders any error the same way. Built-in handling for generic Error and Axios errors. Add more error types without touching call sites:
import { registerErrorHandler } from "log-portal";
registerErrorHandler({
name: "jwt",
match: (e) => e?.name === "TokenExpiredError",
normalize: (e) => ({
name: e.name, message: e.message,
code: "JWT_EXPIRED", data: { expiredAt: e.expiredAt },
}),
});Every log emitted during an HTTP request shares a requestId (from the inbound X-Request-Idheader or a generated UUID, also returned on the response), so a failing request's logs cluster together.
9. Optional: route winston into the portal
import { createLogPortalTransport } from "log-portal";
manualLogger.add(createLogPortalTransport());10. Read API
All read endpoints live under the base path (default /api/_logs) and return a { status, data } envelope.
<base>Historical, newest-first. Query: level, from, to, q, requestId, limit (≤1000), before (older-page cursor). Returns { entries, nextBefore, hasMore }.
<base>/streamLive SSE. Query: level, requestId, after. Honors Last-Event-ID to backfill missed entries on reconnect.
<base>/levelsLog levels + their { text, bg, border } colors (for filter chips / row styling).
<base>/datesAvailable YYYY-MM-DD log files (newest first).
<base>/health{ ok, dir, dates }.
11. Auth
There are two ways to protect the read routes.
Option 1 — Bring your own middleware
Every read route runs behind options.authMiddleware (a no-op by default). Drop your check (e.g. an email whitelist) there with no route changes. This always takes precedence over the built-in auth:
mountLogPortal(app, { authMiddleware: requireWhitelistedEmail });Option 2 — Built-in code auth
Set LOG_PORTAL_AUTH_ENABLED=true plus the four credential vars (LOG_PORTAL_AUTH_SECRET, LOG_PORTAL_ADMIN_EMAIL, LOG_PORTAL_ADMIN_PASSWORD, LOG_PORTAL_ADMIN_SECURITY_CODE) and the package manages access itself — no external store.
- An admin (bootstrapped from those env vars) logs in once and gets a signed, expiring session token used to manage developers.
- Each developer is issued a long random code, shown to the admin once at generation (only a SHA-256 hash is stored). They log in with email + code at
POST <base>/loginand pass the returned token asAuthorization: Bearer <token>(orX-Log-Portal-Token) on every read — including the SSE stream. Revoking/removing takes effect on the next request.
If auth is enabled but not fully configured, read routes fail closed with 503. If left disabled and no authMiddleware is supplied, the routes are open.
12. Admin management API
All under <base>/admin, returning the standard envelope. Every route except login requires Authorization: Bearer <admin-session-token>. :email must be URL-encoded (e.g. dev%40x.com).
| Method | Path | Body → data |
|---|---|---|
| POST | /admin/login | { email, password, securityCode } → { token, expiresAt } |
| GET | /admin/developers | → [{ email, createdAt, lastUsedAt, expiresAt, active }] |
| POST | /admin/developers | { email, expiresInDays? | expiresAt? } → { email, code, … } |
| POST | /admin/developers/:email/regenerate | → { email, code } (new one-time code) |
| POST | /admin/developers/:email/revoke | → { email, active: false } |
| POST | /admin/developers/:email/activate | → { email, active: true } |
| DELETE | /admin/developers/:email | → { email, removed: true } |
When adding a developer, set a time limit with expiresInDays (positive number) or expiresAt (absolute future ISO date); omit both for unlimited access. Expiry is enforced at login and on every read. The one-time code is shown only at create/regenerate — copy it immediately.
13. Managing logs (admin)
Since retention is off by default, logs accumulate until you act. These admin-only routes (same admin token) let you see disk usage, archive logs as a zip, and reclaim space. Selection is by file name (from the listing); the dashboard groups by date.
| Method | Path | Body → response |
|---|---|---|
| GET | /admin/logs | → { files: [{ name, date, seg, size, modifiedAt }], totalBytes, count } |
| POST | /admin/logs/download | { files: string[] } → binary application/zip |
| POST | /admin/logs/delete | { files: string[] } → { deleted, notFound, invalid, freedBytes } |
File names are validated against the log-file pattern and confined to the logs directory, so traversal attempts land in invalid and are never touched. Intended flow: offer download first (a zip for safekeeping), then delete to free space — the two actions are independent. This dashboard surfaces all of this under the admin panel.
14. Optional: end-to-end payload encryption
Set LOG_PORTAL_E2E_ENABLED=true (requires built-in auth) to encrypt request and response bodies on authenticated routes with AES-256-GCM. The key is derived per session — both sides run HKDF-SHA256(token) over the login token, so nothing extra is exchanged. Login responses stay plaintext (no token/key yet).
- Server → client: every authenticated JSON response becomes
{ "enc": "<base64>" }where the blob isiv(12) || ciphertext || tag(16). SSEdata:lines are individually encrypted (theid:cursor stays plain). - Client → server: send request bodies as
{ "enc": "<base64>" }; the server decrypts before the controller runs.
In this dashboard you just match the E2E toggle to the backend — encryption happens server-side in the proxy.