Log Portal/ Docs

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 URL

1. 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.0

express 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 logs

That'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 edits

The 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:

URLUse
http://localhost:4000/api/_logshistorical logs (newest first)
http://localhost:4000/api/_logs/streamlive SSE stream
http://localhost:4000/api/_logs/healthquick 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:

OptionEnv varDefault
basePathLOG_PORTAL_BASE_PATH/api/_logs
logsDirLOG_PORTAL_DIR<cwd>/logs
retentionDaysLOG_PORTAL_RETENTION_DAYS-1 (off)
maxFileMbLOG_PORTAL_MAX_FILE_MB0 (off)
flushIntervalMsLOG_PORTAL_FLUSH_MS0 (sync)
maxBufferLinesLOG_PORTAL_BUFFER_LINES1000
captureConsoleLOG_PORTAL_CAPTURE_CONSOLEtrue
logRequestsLOG_PORTAL_LOG_REQUESTStrue
detectThreatsLOG_PORTAL_DETECT_THREATStrue
maxUrlLengthLOG_PORTAL_MAX_URL_LEN2000
enabledLOG_PORTAL_ENABLEDtrue

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. Default 0 = 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 meta

logger.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.

GET<base>

Historical, newest-first. Query: level, from, to, q, requestId, limit (≤1000), before (older-page cursor). Returns { entries, nextBefore, hasMore }.

GET<base>/stream

Live SSE. Query: level, requestId, after. Honors Last-Event-ID to backfill missed entries on reconnect.

GET<base>/levels

Log levels + their { text, bg, border } colors (for filter chips / row styling).

GET<base>/dates

Available YYYY-MM-DD log files (newest first).

GET<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>/login and pass the returned token as Authorization: Bearer <token> (or X-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).

MethodPathBody → 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.

MethodPathBody → 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 is iv(12) || ciphertext || tag(16). SSE data: lines are individually encrypted (the id: 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.