A drop-in log viewer UI & dashboard for Node.js (Express and NestJS) — search, filter, date-range queries, a time-series volume chart, CSV export, JWT auth, and pluggable memory/file storage for your Winston/Pino-style JSON logs. Self-hosted, zero infrastructure.
Secure sign-in — JWT auth with brute-force lockout.
Dashboard — metric cards plus a clickable, time-series log-volume chart by level.
Quality insights — error/warn rates, top noisy sources, recurring errors, and spike hours.
Searchable log table — paginated, with expandable rows for multi-line messages and metadata.
Why vibe-logger?
- Zero infra — one Express middleware; no separate log server, database, or agent to run.
- Reads what you already write — point it at your existing Winston/Pino JSON log files, or capture logs in-process.
- Insight, not just tailing — time-series chart, error/warn rates, noisy-source and spike detection, plus search, filters, and CSV export.
Built by the engineering team at ViitorCloud Technologies, who use it in production on client projects.
Repository: github.com/vcian/vibe-logger
npm install @vcian/vibe-loggerimport 'dotenv/config';
import express from 'express';
import { createLoggerUI } from '@vcian/vibe-logger';
const app = express();
const logger = createLoggerUI({ path: '/logs', storageMode: 'memory', interceptConsole: true });
app.use(logger.middleware());
app.listen(3000, () => logger.info('Listening on http://localhost:3000/logs'));Then open http://localhost:3000/logs and sign in. Full setup — auth, NestJS, and file mode — is documented below.
⚠️ Maintainers: verify every cell in this table against the current behaviour of each tool before publishing. A wrong claim in a comparison table is the fastest route to a hostile comment thread.
| vibe-logger | Errsole | log.io / frontail | |
|---|---|---|---|
| Setup | One middleware, zero infra | Logger + storage backend | Separate server process |
| Reads existing Winston/Pino files | ✅ | Own logger required | Tails raw text |
| Charts & insights | ✅ time-series, error rates, spike detection | ✅ | ❌ |
| Auth built in | ✅ JWT + lockout | ✅ | ❌ |
| Works inside NestJS | ✅ | Partial | ❌ |
- Configuration Reference
- Environment Variables
- Authentication
- Public API
- REST Endpoints
- Dashboard Walkthrough
- Writing Logs (Best Practices)
- CSV Export
- Security
- Troubleshooting
- Contributing / Changelog / License
vibe-logger mounts a small Express router that serves a self-contained log dashboard plus a JSON API. It works in two distinct modes:
| Mode | Source of logs | Use when |
|---|---|---|
| Memory | Logs you write via logger.info/warn/error/debug (and optionally console.*) |
You want a live, in-process viewer with zero infrastructure. |
| File | A Winston-style JSON log file (or many files via glob) | You already write JSON logs to disk and want to browse them. |
- Time-series volume chart by level (Chart.js), clickable to drill into an hour.
- Search, level filter, source filter, date range (presets + custom), removable filter chips.
- Expandable rows for multi-line messages and structured metadata.
- Quality insights: error/warn rate, top noisy sources, recurring errors, spike hours.
- CSV export of the currently filtered set.
.env-driven auth (bcrypt + JWT cookie), brute-force lockout, multi-user roles.- Responsive UI for desktop / laptop / tablet.
- TypeScript types shipped, zero hard framework dependency beyond Express.
npm install @vcian/vibe-loggerRequirements:
- Node.js
>=16 express >=4.18(peer dependency)- For NestJS users: the Express platform adapter (default in
@nestjs/platform-express).
import 'dotenv/config';
import express from 'express';
import { createLoggerUI } from '@vcian/vibe-logger';
const app = express();
const logger = createLoggerUI({
path: '/logs',
source: 'app',
storageMode: 'memory',
interceptConsole: true,
});
app.use(logger.middleware());
logger.info('Server started', { port: 3000 });
app.listen(3000, () => logger.info('Listening on http://localhost:3000/logs'));Open http://localhost:3000/logs and sign in with the credentials configured in your .env file (see §8).
vibe-logger is just an Express router, so it works inside any NestJS app that uses the default Express adapter (@nestjs/platform-express).
Mount the middleware directly on the underlying Express instance in main.ts:
import 'dotenv/config';
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { createLoggerUI } from '@vcian/vibe-logger';
import { AppModule } from './app.module';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const logger = createLoggerUI({
path: '/logs',
source: 'nest-app',
storageMode: 'file',
filePath: 'logs/app.log',
});
app.use(logger.middleware());
(global as any).logger = logger;
await app.listen(3000);
}
bootstrap();Wrap the logger so any service can inject and use it.
// logger.module.ts
import { Module, Global } from '@nestjs/common';
import { createLoggerUI, LoggerUI } from '@vcian/vibe-logger';
export const LOGGER = Symbol('LOGGER');
@Global()
@Module({
providers: [
{
provide: LOGGER,
useFactory: (): LoggerUI =>
createLoggerUI({
path: '/logs',
source: 'nest',
storageMode: (process.env.LOG_STORAGE_MODE as 'memory' | 'file') ?? 'memory',
}),
},
],
exports: [LOGGER],
})
export class LoggerModule {}// main.ts
import { NestFactory } from '@nestjs/core';
import { NestExpressApplication } from '@nestjs/platform-express';
import { AppModule } from './app.module';
import { LOGGER } from './logger.module';
import type { LoggerUI } from '@vcian/vibe-logger';
async function bootstrap() {
const app = await NestFactory.create<NestExpressApplication>(AppModule);
const logger = app.get<LoggerUI>(LOGGER);
app.use(logger.middleware());
await app.listen(3000);
}
bootstrap();// any.service.ts
import { Inject, Injectable } from '@nestjs/common';
import type { LoggerUI } from '@vcian/vibe-logger';
import { LOGGER } from './logger.module';
@Injectable()
export class PaymentsService {
constructor(@Inject(LOGGER) private readonly logger: LoggerUI) {}
charge(orderId: string) {
this.logger.info('Charge started', { orderId });
}
}Forward all unhandled exceptions into the viewer:
import { ArgumentsHost, Catch, ExceptionFilter, HttpException } from '@nestjs/common';
import type { LoggerUI } from '@vcian/vibe-logger';
@Catch()
export class VibeLoggerFilter implements ExceptionFilter {
constructor(private readonly logger: LoggerUI) {}
catch(exception: any, host: ArgumentsHost) {
const ctx = host.switchToHttp();
const req = ctx.getRequest();
const status = exception instanceof HttpException ? exception.getStatus() : 500;
this.logger.error(
`${exception?.name ?? 'Error'}: ${exception?.message ?? 'Unknown'}\n${exception?.stack ?? ''}`,
{ method: req.method, url: req.url, statusCode: status },
);
throw exception;
}
}Fastify users: This package targets the Express adapter. To use it under
@nestjs/platform-fastify, either expose an Express bridge for the/logsroute or migrate that route to Express middleware.
You pick the mode once at bootstrap. The dashboard, API, filters, chart, and CSV export are identical across modes — only the data source changes.
In-process ring buffer capped by maxEntries. The oldest entries are evicted when the cap is reached.
const logger = createLoggerUI({
storageMode: 'memory',
maxEntries: 10_000,
interceptConsole: true,
});| Characteristic | Behaviour |
|---|---|
| Persistence | None — logs are lost on process restart. |
| Ingestion | logger.info/warn/error/debug(...) and (optionally) console.* interception. |
| Cap | maxEntries (default 10000 or LOG_MAX_ENTRIES). |
| Multi-process | Each process has its own buffer. Use file mode (or a shared store) for clusters. |
| Best for | Local dev, single-process apps, demos, integration tests. |
Memory mode is the default and requires no config beyond auth.
Reads a Winston-style JSON log file (one JSON object per line). The viewer parses the file on demand and re-parses it when the file is modified (if live tail is on).
const logger = createLoggerUI({
storageMode: 'file',
filePath: 'logs/app.log',
fileLiveTail: true,
maxEntries: 50_000,
});Expected line format
{"timestamp":"2026-05-12T07:00:00.000Z","level":"info","message":"Request complete","source":"api","meta":{"status":200,"durationMs":42}}Rules:
- Supported keys:
timestamp,level,message,source,meta. - Any extra keys are folded into
meta. - Malformed lines are skipped (the rest still load).
- Accepted timestamps: ISO strings, Winston
YYYY-MM-DD HH:mm:ss, epoch seconds or milliseconds (number or string). - If a timestamp can't be parsed, the entry is still ingested with “now” and tagged
meta._timestampParseFailed=true.
Live tail
With fileLiveTail: true (default), the file is re-read whenever its mtime changes — no socket, no polling daemon. Set to false if you want a static snapshot.
For setups where each day is a separate file (logs/2026-05-11.log, logs/2026-05-12.log, …):
const logger = createLoggerUI({ storageMode: 'file' });LOG_STORAGE_MODE=file
LOG_FILE_GLOB=logs/*.log
LOG_FILE_DAYS=30
LOG_FILE_LIVE_TAIL=trueRules:
- Filenames must contain a
YYYY-MM-DDdate — that's how the day window is computed. - Only files within the last
LOG_FILE_DAYSdays are loaded. LOG_FILE_GLOBtakes precedence overLOG_FILE_PATH(unless you passfilePathexplicitly tocreateLoggerUI).- The combined entry count is capped by
LOG_MAX_ENTRIES(newest kept).
Inject your own implementation of the LogStore interface (e.g. Redis, SQLite, S3-backed):
import { createLoggerUI, LogStore } from '@vcian/vibe-logger';
const myStore: LogStore = {
append(entry) { /* ... */ return { ...entry, id: 'uuid' } as any; },
query(opts) { /* ... */ return { data: [], total: 0 }; },
stats() { /* ... */ return /* StatsResult */ as any; },
clear() { /* ... */ },
getSources() { return []; },
};
const logger = createLoggerUI({ store: myStore });When store is provided, storageMode / filePath / maxEntries are ignored.
All options accepted by createLoggerUI(options):
| Option | Type | Default | Description |
|---|---|---|---|
path |
string |
"/logs" (or LOG_VIEWER_PATH) |
Mount path for the UI and API. |
source |
string |
"app" |
Default source label for logger.info/warn/error/debug. |
interceptConsole |
boolean |
false |
Capture console.log/info/warn/error/debug into the store. |
storageMode |
`"memory" | "file"` | "memory" (or LOG_STORAGE_MODE) |
maxEntries |
number |
10000 (or LOG_MAX_ENTRIES) |
Cap for memory/file stores (oldest entries evicted). |
filePath |
string |
logs/app.log (or LOG_FILE_PATH) |
Single-file path when storageMode: 'file'. |
fileLiveTail |
boolean |
true (or LOG_FILE_LIVE_TAIL) |
Reload file(s) when modified. |
store |
LogStore |
undefined |
Inject a custom store (overrides all storage options). |
Resolution order for each setting: explicit option → env var → built-in default.
All vars are read from process.env (load via dotenv or your platform).
| Key | Default | Description |
|---|---|---|
LOG_AUTH_ENABLED |
true |
Master switch. When true, all UI and API routes require login. |
LOG_USERNAME |
admin |
Single-user username. |
LOG_PASSWORD_HASH |
required when auth on | Bcrypt hash of the password (use the CLI below). |
LOG_JWT_SECRET |
required, ≥32 chars | Signing secret for the session JWT. |
LOG_SESSION_TTL |
3600 |
Session length in seconds. |
LOG_MAX_ATTEMPTS |
5 |
Failed logins allowed before lockout. |
LOG_LOCKOUT_MINS |
15 |
Lockout duration after LOG_MAX_ATTEMPTS. |
LOG_USERS |
empty | Optional JSON array for multi-user mode (overrides LOG_USERNAME/LOG_PASSWORD_HASH). |
| Key | Default | Description |
|---|---|---|
LOG_VIEWER_PATH |
/logs |
Default mount path (overridable via path option). |
LOG_MAX_ENTRIES |
10000 |
Entry cap for memory/file stores. |
LOG_STORAGE_MODE |
memory |
memory or file. |
LOG_FILE_PATH |
logs/app.log |
Single JSON log file. |
LOG_FILE_GLOB |
empty | Glob for daily-rotate mode (e.g. logs/*.log). |
LOG_FILE_DAYS |
30 |
Days included when using LOG_FILE_GLOB. |
LOG_FILE_LIVE_TAIL |
true |
Reload file(s) when their mtime changes. |
LOG_AUTH_ENABLED=true
LOG_USERNAME=admin
LOG_PASSWORD_HASH=$2a$10$replace_with_real_hash
LOG_JWT_SECRET=a-long-random-string-of-32-chars-or-more
LOG_SESSION_TTL=3600
LOG_STORAGE_MODE=file
LOG_FILE_PATH=logs/app.log
LOG_FILE_LIVE_TAIL=true
LOG_MAX_ENTRIES=50000Startup fails fast if
LOG_AUTH_ENABLED=trueand any ofLOG_JWT_SECRET/LOG_PASSWORD_HASHare missing, if the secret is the placeholder value, or if it's shorter than 32 characters.
npx @vcian/vibe-logger hash-password "your-password"Copy the output into LOG_PASSWORD_HASH.
LOG_USERNAME=admin
LOG_PASSWORD_HASH=$2a$10$...Set LOG_USERS to a JSON array (this overrides LOG_USERNAME / LOG_PASSWORD_HASH):
[
{ "username": "admin", "passwordHash": "$2a$10$...", "role": "admin" },
{ "username": "viewer", "passwordHash": "$2a$10$...", "role": "viewer" }
]- Login
POST <mountPath>/auth/login(e.g.POST /logs/auth/login) issues anlgr_sessionJWT cookie. - Cookie flags:
httpOnly,sameSite: 'strict',securein production. - TTL controlled by
LOG_SESSION_TTL. - Logout
POST <mountPath>/auth/logoutclears the cookie.
The login route is rate-limited. After LOG_MAX_ATTEMPTS failures, the client is locked out for LOG_LOCKOUT_MINS minutes.
LOG_AUTH_ENABLED=falseNever disable auth on a network-reachable instance.
import { createLoggerUI, LoggerUI, LogStore, LogEntry } from '@vcian/vibe-logger';Returns:
interface LoggerUI {
middleware(): Router; // mount with app.use(...)
info(message: string, meta?: object): void;
warn(message: string, meta?: object): void;
error(message: string, meta?: object): void;
debug(message: string, meta?: object): void;
}| Key | Effect |
|---|---|
_timestamp |
ISO string override for the entry's timestamp (useful for replays/backfills). |
LogEntry, LogLevel, LogStore, QueryOptions, QueryResult, StatsResult, StorageMode, CreateLogStoreOptions, HourBucket, SourceInsight, ErrorMessageInsight, SpikeHourInsight.
All paths below are relative to the mount path (default /logs). With the default mount path, prepend /logs to get the full URL — e.g. GET /logs/logs, POST /logs/auth/login.
All routes (except /login and the static assets) require auth when LOG_AUTH_ENABLED=true.
| Method | Path | Description |
|---|---|---|
GET |
/ |
HTML dashboard. |
GET |
/login |
Login HTML page. |
GET |
/dashboard.css |
Dashboard stylesheet (static asset). |
GET |
/dashboard.js |
Dashboard script (static asset). |
POST |
/auth/login |
{ username, password } → sets lgr_session JWT cookie. |
POST |
/auth/logout |
Clears the lgr_session cookie. |
GET |
/logs |
Paginated log list. Query params: level, source, search, startDate, endDate, limit, offset. |
GET |
/logs/stats |
Aggregates for the active filter set: totals by level, hour buckets, error/warn rate, top noisy sources, recurring errors, spike hours. Also returns the sources array used to populate the filter dropdown. |
GET |
/logs/export/csv |
CSV download of the filtered set (same query params as GET /logs). |
No separate sources endpoint. Source labels are returned in the
sourcesfield of theGET /logs/statsresponse — there is no standalone/sourcesroute.
Open the mount path in a browser. The page is laid out top-down for fast triage.
- Metric cards — Total / Errors / Warnings / Info for the active filter set.
- Log Volume by Hour — stacked bar chart by level. Click a bar to filter to that hour; click again to clear.
- Quality Insights
- Error Rate / Warn Rate — health signal scoped to current filters.
- Top Noisy Sources — highest-volume sources with their own error rate.
- Recurring Error Messages — most repeated error texts.
- Spike Hours — hours with unusually high activity or error concentration.
- Filter toolbar — search (debounced, matches message + source), level dropdown, source dropdown, date range (presets
1h/24h/7d/CustomwithApply/Cancel/Clearand inline validation),Clear Filters,Export CSV. Active filters appear as removable chips. - Log table — Timestamp, Level (coloured badge), Source, Message. Only the first line of the message is shown; a
▸caret marks rows with more content. Click a row to expand:
- Full message — complete multi-line content, monospaced and scrollable.
- Metadata — pretty-printed JSON of the
metapayload.
The UI is responsive (desktop / laptop / tablet) and re-flows toolbar, insights, and pagination at smaller widths.
The viewer renders only the first line of the message in the table and tucks the rest into the expandable detail panel. Structure your logs around that.
Keep the first line short and scannable. Put context in meta.
logger.info('Request complete', { method: 'GET', path: '/users', status: 200, durationMs: 42 });
logger.warn('Slow query detected', { sqlHash: 'ab12', durationMs: 1800, rows: 3200 });
logger.error('Failed to charge customer', { customerId: 'cus_123', provider: 'stripe', code: 'card_declined' });try {
await userService.load(id);
} catch (error) {
logger.error(
`${error.name}: ${error.message}\n${error.stack}`,
{ requestId: req.id, userId: id },
);
}app.use((err, req, _res, next) => {
logger.error(
`${err.name ?? 'Error'}: ${err.message}\n${err.stack ?? ''}`,
{ method: req.method, url: req.originalUrl, ip: req.ip, statusCode: err.status ?? 500 },
);
next(err);
});| Level | Use for |
|---|---|
info |
Normal lifecycle events, successful requests, state transitions. |
warn |
Recoverable issues, slow operations, deprecation hits. |
error |
Thrown exceptions, failed jobs, 5xx responses. |
debug |
Verbose diagnostics for non-production. |
Set interceptConsole: true. Any console.log/info/warn/error/debug from third-party or legacy code will appear in the viewer with no refactor.
Pass meta._timestamp (ISO string) to override the ingestion time. Useful when importing past data.
- Endpoint:
GET /logs/export/csv(relative to the mount path; with default mount/logsthe full URL is/logs/logs/export/csv). - Columns:
id, timestamp, level, source, message, meta. metais JSON-encoded.- Filename:
logs-YYYY-MM-DD.csv. - Respects the same query parameters as
GET /logs(so the toolbar's filters carry over to the download).
- Auth routes are rate-limited.
- JWT cookies are
httpOnly,sameSite: 'strict', andsecurein production. - Startup validation refuses to boot with a missing/short/placeholder
LOG_JWT_SECRETwhen auth is enabled. - The package never logs the password or JWT.
- File mode reads only the paths you configure — globs are resolved relative to the process CWD, not user input.
- Responsible disclosure: see SECURITY.md.
| Symptom | Likely cause / fix |
|---|---|
Missing required environment variables at startup |
Set LOG_JWT_SECRET and LOG_PASSWORD_HASH, or set LOG_AUTH_ENABLED=false for local dev. |
LOG_JWT_SECRET must be at least 32 characters long. |
Generate a longer random secret. |
Logout button visible when LOG_AUTH_ENABLED=false |
Upgrade to the latest version — older builds showed auth UI unconditionally; it is now hidden when auth is disabled. |
| Logging in succeeds but the page redirects to login | Cookie blocked. In production, ensure HTTPS so the secure cookie flag works; behind a proxy, set app.set('trust proxy', 1). |
| Memory mode shows no logs after restart | Expected — memory mode is non-persistent. Switch to file mode. |
| File mode shows no logs | Confirm the file exists, is readable, and contains one JSON object per line. Check meta._timestampParseFailed on rows. |
| Daily glob shows no logs | Ensure filenames contain YYYY-MM-DD and fall within LOG_FILE_DAYS. |
| 401 on every request after deploy | Clock skew can invalidate JWTs — sync server time (NTP). |
| NestJS + Fastify: UI doesn't load | Use @nestjs/platform-express or bridge the route via Express. |
npm audit reports vulnerabilities after install |
Run npm install with the latest published version — the package pins safe dependency ranges via overrides in package.json. |
- Source & issues: github.com/vcian/vibe-logger
- Contributing: CONTRIBUTING.md
- Code of Conduct: CODE_OF_CONDUCT.md
- Changelog: CHANGELOG.md
- Security policy: SECURITY.md
- License: MIT — see LICENSE.
ViitorCloud Technologies is an AI-first technology services company building AI/ML, data, and modern web solutions for clients across North America, Europe, and APAC. Our engineering team builds and maintains this package — and uses it in production on client projects.
- 🌐 viitorcloud.com
- 🧰 More open source from ViitorCloud
- 💬 Questions or commercial support: support@viitorcloud.com
- 💼 Work with our team
If this package saves you time, ⭐ star the repo — it's the main way other developers discover it.



