@ndriadev/vite-plugin-monitor
v2.3.0
Published
Automatic User Interaction Tracking, Real-Time Dashboard & File Logging for Vite
Maintainers
Readme
vite-plugin-monitor
Automatic User Interaction Tracking, Real-Time Dashboard & File Logging for Vite
Built with:
📋 Table of Contents
- Overview
- Features
- Installation
- Quick Start
- Storage Modes
- Configuration
- Client API
- Usage Examples
- Dashboard
- Debug Overlay
- API Contracts
- Important Notes
- Troubleshooting
- Contributing
- License
🎯 Overview
vite-plugin-monitor is a Vite plugin that adds automatic user interaction tracking, server-side event logging, and a built-in real-time dashboard to any Vite application — with a single entry in vite.config.ts and zero application code changes required.
It intercepts browser interactions at the lowest level (before any application code runs) and forwards them to a configurable backend, with four storage modes covering every deployment scenario from local development to production:
- 🔌 Middleware mode (default in dev) — Events are handled directly by Vite's dev server. Zero external processes needed.
- 🌐 HTTP mode (required in production) — Events are sent to your own API endpoint. Bring your own backend.
- ⚡ WebSocket mode — All traffic (ingest + dashboard queries) flows over a single persistent WebSocket connection.
✨ Features
🔍 Automatic Trackers
- Clicks — Single passive
clicklistener via event delegation. Captures element tag, text, attributes, and route. - HTTP Requests — Patches
fetchandXMLHttpRequest. Captures method, URL, status code, and duration. Optional capture of sanitized headers and bodies. - Unhandled Errors — Hooks into
window.onerror(sync) andunhandledrejection(Promise). Captures message, stack, and source location. - Navigation — Intercepts
history.pushState,replaceState,popstate,hashchange, and emits a synthetic 'load' navigation synchronously at setup time. Compatible with all major SPA routers. - Console — Intercepts all 19
consolemethods. Configurable per-method, with argument length limits and ignore patterns.
📦 Event Transport
- Client-side batching with configurable
batchSizeandflushInterval - Guaranteed delivery on page unload via
navigator.sendBeacon - Automatic retry on failed flushes
- Optional
X-Tracker-KeyAPI key header on all requests
🗄️ Server-Side Logging
- Non-Blocking I/O — All file writes use Node's non-blocking
fs.WriteStreamAPI directly on the main thread. Zero blocking on the Vite event loop. - JSONL format — One JSON-stringified
TrackerEventper line, machine-readable and replay-friendly. - Pretty format — Human-readable aligned columns for local debugging.
- Log rotation — Daily (UTC midnight) or size-based. Configurable archive count.
- Multiple transports — Write the same event stream to several files simultaneously (e.g. JSONL for machines, pretty for humans).
- Replay on restart — On startup the middleware server replays existing log files into its in-memory ring buffer so the dashboard retains history across Vite restarts.
📊 Built-in Dashboard
- Vanilla TypeScript SPA bundled separately — no framework dependencies, isolated in Shadow DOM.
- KPI cards: Active Sessions, Total Events, Unique Users, App Error Rate.
- Charts: Event Volume (line/bar) and Total Error Rate % timeline.
- Top lists: Top Pages, Top App Errors, Navigation Funnel, Top Endpoints.
- HTTP stats: Most Called Endpoint, Avg HTTP Duration, HTTP Error Rate, Slowest Endpoint, and HTTP status breakdowns (2xx / 4xx / 5xx).
- Full events table with type, level, userId, route filters and full-text search.
- Event detail panel with deep-inspection of any event payload.
- Configurable time ranges: Live, 1h, 6h, 24h, 7d, 30d.
- Optional login gate (client-side, HMAC-hashed credentials).
- Polling interval configurable per environment.
- Single unified polling loop — one fetch per tick shared between the metrics pipeline and the events table, minimising network traffic and main-thread JSON parsing overhead.
🔬 Debug Overlay
- Floating FAB button, Shadow DOM–isolated, drag-and-drop repositionable.
- Shows: User ID (editable inline), Session ID, App ID, current route, viewport size, language, and network connection type.
- Quick link to open the dashboard in a new tab.
- Dark/light theme toggle with
localStoragepersistence. Alt+Tkeyboard shortcut.- "Remove Tracker Info" button to cleanly call
overlay.destroy().
🛡️ Security by Default
- Sensitive HTTP headers (
Authorization,Cookie,Set-Cookie,X-Api-Key, etc.) are always stripped from captured requests and responses — configurable, but cannot be un-redacted. - JSON body keys matching built-in patterns (
password,token,secret,card,cvv,iban, etc.) are always replaced with'[REDACTED]'recursively. - Dashboard credentials are HMAC-hashed with
appIdbefore being written towindow.__TRACKER_CONFIG__. window.__TRACKER_CONFIG__is frozen and made non-writable/non-configurable at injection time.
📦 Installation
# pnpm (recommended)
pnpm add -D @ndriadev/vite-plugin-monitor
# npm
npm install -D @ndriadev/vite-plugin-monitor
# yarn
yarn add -D @ndriadev/vite-plugin-monitorRequirements
- Node.js:
>=20.19.0 - Vite:
>=4.0.0
🚀 Quick Start
Minimal Setup
Add the plugin to vite.config.ts. Only appId is required — everything else uses opinionated defaults that work out of the box for local development.
// vite.config.ts
import { defineConfig } from 'vite';
import { trackerPlugin } from '@ndriadev/vite-plugin-monitor';
export default defineConfig({
plugins: [
trackerPlugin({
appId: 'my-app',
})
]
});With just this configuration:
- The plugin auto-selects middleware mode: events are stored in Vite's dev server memory and written to
./logs/my-app.log. - Console tracking is enabled by default (all 19 methods). Click, HTTP, error, and navigation tracking are opt-in (
falseby default). - The dashboard and overlay are disabled by default.
To get something useful immediately:
trackerPlugin({
appId: 'my-app',
track: {
clicks: true,
http: true,
errors: true,
navigation: true,
},
dashboard: {
enabled: true,
route: '/_dashboard',
},
overlay: {
enabled: true,
position: 'bottom-right',
},
})Open http://localhost:5173/_dashboard to see the live dashboard. The overlay FAB appears on every page.
🗄️ Storage Modes
| Mode | When to use | writeEndpoint | readEndpoint |
|------|-------------|---------------|--------------|
| 'auto' (default) | Dev: auto-selects middleware. Build: requires writeEndpoint. | Optional | Optional |
| 'middleware' | Dev/preview: Vite handles everything, no extra process | Same-origin /_tracker/events | Same-origin /_tracker |
| 'http' | Production: your own REST API handles events | Required | Optional (inferred from writeEndpoint) |
| 'websocket' | Production: single persistent WS connection | — | — |
Production builds require an explicit mode. Setting
mode: 'auto'without awriteEndpointon avite buildthrows a configuration error at build time — by design.
⚙️ Configuration
Plugin Options
import { trackerPlugin } from '@ndriadev/vite-plugin-monitor';
trackerPlugin({
/**
* Master switch. When false, the plugin is a complete no-op:
* no scripts injected, no server started, no logs written.
* @default true
*/
enabled?: boolean;
/**
* Unique identifier for this application.
* Attached to every event as TrackerEvent.appId.
* Required — throws at startup if missing.
*/
appId: string;
/**
* Which browser interactions to track and how.
* @see TrackOptions
*/
track?: TrackOptions;
/**
* Event storage backend and transport parameters.
* @see StorageOptions
*/
storage?: StorageOptions;
/**
* Server-side log file configuration.
* @see LoggingOptions
*/
logging?: LoggingOptions;
/**
* Built-in dashboard SPA configuration.
* @see DashboardOptions
*/
dashboard?: DashboardOptions;
/**
* Floating debug overlay widget configuration.
* @see OverlayOptions
*/
overlay?: OverlayOptions;
/**
* Automatically inject tracker.init() into index.html.
* When false, you must call tracker.init() manually.
* @default true
*/
autoInit?: boolean;
})Track Options
track: {
/**
* Enable click tracking.
* true → enable with default settings.
* false → disabled entirely.
* ClickTrackOptions → filter by route or CSS selector (see below).
* Single passive delegated listener on document.
* @default false
*/
clicks?: boolean | ClickTrackOptions;
/**
* Enable HTTP request tracking.
* true → method, URL, status, duration. No headers/bodies.
* false → disabled entirely.
* HttpTrackOptions → full control (see below).
* @default false
*/
http?: boolean | HttpTrackOptions;
/**
* Enable unhandled error tracking.
* true → capture all unhandled errors and promise rejections.
* false → disabled entirely.
* ErrorTrackOptions → filter specific error messages (see below).
* Hooks window.onerror and unhandledrejection.
* try/catch errors are NOT captured automatically.
* @default false
*/
errors?: boolean | ErrorTrackOptions;
/**
* Enable client-side navigation tracking.
* true → enable with default settings.
* false → disabled entirely.
* NavigationTrackOptions → filter by route or trigger type (see below).
* Patches history.pushState, replaceState, popstate,
* hashchange, and emits a synthetic 'load' navigation synchronously at setup time.
* @default false
*/
navigation?: boolean | NavigationTrackOptions;
/**
* Enable console method interception.
* true → all 19 methods with safe defaults.
* false → disabled entirely.
* ConsoleTrackOptions → restrict methods, tune limits.
* @default true
*/
console?: boolean | ConsoleTrackOptions;
/**
* Function resolving the current user ID at init time.
* To update after init, use tracker.setUser().
* @example () => localStorage.getItem('userId')
*/
userId?: () => string | null;
/**
* Minimum log level for automatically-tracked events.
* Events below this threshold are discarded before enqueueing.
* Does not affect tracker.track() custom events.
* Navigation and Click trackers are always emitted at 'info' level.
* @default 'info'
*/
level?: 'debug' | 'info' | 'warn' | 'error';
}ClickTrackOptions (fine-grained click filtering)
clicks: {
/**
* Route patterns where click tracking is suppressed.
* Checked against window.location.pathname at click-time.
* Accepts plain strings (strict equality) or RegExp objects.
* The dashboard route is always injected automatically.
* @default []
* @example ['/admin', /^\/user\/\d+/, '/checkout']
*/
ignoreRoutes?: (string | RegExp)[];
/**
* CSS selectors whose matching elements (or ancestors) suppress click tracking.
* Uses Element.closest() walking up the DOM from the event target.
* The overlay host selector [data-tracker-overlay] is always injected automatically.
* @default []
* @example ['[data-no-track]', '#cookie-banner', '.dev-toolbar']
*/
ignoreSelectors?: string[];
}ErrorTrackOptions (fine-grained error filtering)
errors: {
/**
* Patterns matched against the error message.
* Errors whose message matches any entry are silently dropped.
* String entries use strict equality; RegExp entries are tested against the full message.
* Classic use-case: suppressing browser extension noise.
* @default []
* @example ['ResizeObserver loop limit exceeded', /^Script error\.?$/]
*/
ignoreMessages?: (string | RegExp)[];
}NavigationTrackOptions (fine-grained navigation filtering)
navigation: {
/**
* Route patterns where navigation tracking is suppressed.
* Suppressed when either the `from` OR the `to` path matches.
* Accepts plain strings (strict equality) or RegExp objects.
* The dashboard route is always injected automatically.
* @default []
* @example ['/admin', /^\/user\/\d+/, '/checkout']
*/
ignoreRoutes?: (string | RegExp)[];
/**
* Navigation trigger types to suppress.
* Note: 'load' (initial page load) cannot be suppressed via this option.
* @default []
* @example ['hashchange', 'replaceState']
*/
ignoreTypes?: Array<'pushState' | 'replaceState' | 'popstate' | 'hashchange'>;
}HttpTrackOptions (fine-grained HTTP capture)
http: {
/**
* Capture sanitized request headers.
* Sensitive headers are always stripped regardless.
* @default false
*/
captureRequestHeaders?: boolean;
/**
* Capture and auto-redact the request body.
* Pipeline: read → parse JSON → redact → re-serialize → truncate.
* @default false
*/
captureRequestBody?: boolean;
/**
* Capture sanitized response headers (Set-Cookie always stripped).
* @default false
*/
captureResponseHeaders?: boolean;
/**
* Capture and auto-redact the response body.
* Uses response.clone() — original Response is not consumed.
* @default false
*/
captureResponseBody?: boolean;
/**
* Additional header names to strip (case-insensitive).
* Cannot un-redact a built-in sensitive header.
* @example ['x-internal-trace', 'x-company-id']
*/
excludeHeaders?: string[];
/**
* Additional JSON body key patterns to redact (case-insensitive substring).
* Applied recursively to nested objects and arrays.
* @example ['fiscalCode', 'vatNumber']
*/
redactKeys?: string[];
/**
* Maximum byte length of the stored body after redaction.
* Set to 0 to disable truncation (not recommended).
* @default 2048
*/
maxBodySize?: number;
/**
* HTTP methods to exclude from tracking (case-insensitive).
* Useful for suppressing high-frequency noise like CORS preflight OPTIONS requests.
* @default []
* @example ['OPTIONS', 'HEAD']
*/
ignoreMethods?: string[];
/**
* URLs that disable HTTP tracking for matching requests.
* Accepts plain strings (strict equality) or RegExp objects.
* Case-sensitive match against the full absolute URL.
* Applied before any capture or redaction logic.
* @default []
* @example ['/_dashboard', '/health', /analytics\.google\.com/]
*/
ignoreUrls?: (string | RegExp)[];
}ConsoleTrackOptions (fine-grained console capture)
console: {
/**
* Subset of console methods to intercept.
* Methods not listed are not patched and incur zero overhead.
* @default All 19 methods
* @example ['error', 'warn']
*/
methods?: ConsoleMethod[];
/**
* Maximum character length for a single serialized argument.
* Values exceeding this are truncated with '... [N chars omitted]'.
* @default 1024
*/
maxArgLength?: number;
/**
* Maximum number of arguments captured per console call.
* @default 10
*/
maxArgs?: number;
/**
* Capture a stack trace for console.error calls.
* (console.trace always captures a stack regardless of this flag.)
* @default false
*/
captureStackOnError?: boolean;
/**
* Patterns matched against the first argument of each console call.
* Calls whose first argument matches any entry are silently dropped.
* String entries use strict equality; RegExp entries are tested against
* the string representation of the first argument.
* The built-in patterns '[vite]', '[HMR]', '[tracker]' are always prepended.
* Applied before serialization: zero overhead for ignored calls.
* @default ['[vite]', '[HMR]', '[tracker]']
* @example ['[vite]', '[HMR]', '[tracker]', /^\[react-query\]/, 'Stripe.js']
*/
ignorePatterns?: (string | RegExp)[];
}Storage Options
HttpStorageOptions (modes: auto, middleware, http)
storage: {
/**
* @default 'auto'
*/
mode?: 'auto' | 'middleware' | 'http';
/**
* URL that receives batched events via POST.
* Required when mode = 'http'.
* Body: { "type": "ingest", "events": TrackerEvent[] }
* Any 2xx is treated as success; non-2xx requeues the batch.
*/
writeEndpoint?: string;
/**
* URL queried by the dashboard for events.
* Must honour ?since=<ISO8601>&until=<ISO8601> query params.
* If omitted, inferred by stripping /events from writeEndpoint.
*/
readEndpoint?: string;
/**
* URL polled by the dashboard health check indicator.
* If omitted, backend is assumed online.
*/
pingEndpoint?: string;
/**
* API key sent as X-Tracker-Key on all requests.
*/
apiKey?: string;
/**
* Max events accumulated client-side before flushing.
* @default 25
*/
batchSize?: number;
/**
* Max milliseconds between automatic flushes.
* @default 7000
*/
flushInterval?: number;
/**
* Max events kept in the server-side in-memory ring buffer.
* Only used in middleware mode.
* Oldest events are evicted automatically (FIFO).
* @default 500000
*/
maxBufferSize?: number;
}WsStorageOptions (mode: websocket)
storage: {
mode: 'websocket'; // Required discriminant
wsEndpoint: string; // WebSocket URL, e.g. 'wss://api.myapp.com/tracker/ws'
pingEndpoint?: string; // Optional health check URL
apiKey?: string; // Optional API key
batchSize?: number; // @default 25
flushInterval?: number; // @default 7000
}Logging Options
logging: {
/**
* Minimum severity written to any file transport (server-side only).
* Independent of track.level (client-side filtering).
* @default 'info'
*/
level?: 'debug' | 'info' | 'warn' | 'error';
/**
* File output targets. Multiple transports write simultaneously.
* @default [{ format: 'json', path: './logs/<appId>.log', rotation: { strategy: 'daily', maxFiles: 30 } }]
*/
transports?: Array<{
/**
* 'json' → JSONL (one TrackerEvent per line). Machine-readable.
* 'pretty' → Human-readable aligned columns.
*/
format: 'json' | 'pretty';
/**
* Log file path (absolute or CWD-relative).
* For daily rotation a date suffix is inserted before the extension:
* './logs/monitor.log' → './logs/monitor-2024-03-15.log'
*/
path: string;
rotation?: {
/**
* 'daily' → first write after UTC midnight triggers rotation.
* 'size' → first write that would exceed maxSize triggers rotation.
*/
strategy: 'daily' | 'size';
/** Max size for 'size' strategy. @default '10mb' */
maxSize?: string;
/** Max rotated archive files to retain on disk. @default 30 */
maxFiles?: number;
/** Reserved for future gzip compression. Currently no effect. @default false */
compress?: boolean;
};
}>;
}Dashboard Options
dashboard: {
/**
* Enable the dashboard SPA.
* @default false
*/
enabled?: boolean;
/**
* URL path where the dashboard is mounted.
* Must start with '/'. Should not collide with app routes.
* @default '/_dashboard'
*/
route?: string;
/**
* Login gate credentials (client-side, HMAC-hashed).
* Suitable for dev/staging friction — not for production security.
* false → no login required.
* @default false
*/
auth?: { username: string; password: string } | false;
/**
* Copy the dashboard SPA into the vite build output (dist/).
* When true, the dashboard is served as static files at `route`.
* Requires storage.readEndpoint to be set.
* @default false
*/
includeInBuild?: boolean;
/**
* Polling interval between dashboard data refresh requests (ms).
* @default 10000
*/
pollInterval?: number;
}Overlay Options
overlay: {
/**
* Show the floating debug overlay.
* @default false
*/
enabled?: boolean;
/**
* Corner where the FAB button is anchored.
* @default 'bottom-right'
*/
position?: 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left';
}🖥️ Client API
By default (autoInit: true) the plugin injects two scripts into index.html at head-prepend before any application code:
- Setup script — Installs all event proxies (
fetch,XHR,console,history, etc.) immediately. Events are enqueued but not yet flushed. - Auto-init script — Calls
tracker.init(), activates the flush timer, mounts the overlay, and emits the initialsession:startevent.
Manual Initialization
Set autoInit: false when you need to delay initialization — for example after a cookie consent banner, after authentication, or only in specific environments.
// vite.config.ts
trackerPlugin({
appId: 'my-app',
autoInit: false, // setup proxies immediately, but don't init yet
track: { clicks: true, http: true, errors: true, navigation: true },
})The setup script is still injected (proxies must be active before app code), but tracker.init() is not called. Call it yourself at the right moment:
// In your application code (e.g. after consent, after login)
import { tracker } from '@ndriadev/vite-plugin-monitor/client';
// Simple manual init
tracker.init();
// With user ID function
tracker.init(() => authStore.getState().userId ?? null);tracker Object
The tracker object is a safe proxy — most calls are silently dropped if the tracker has not been initialized yet (e.g. before tracker.init() or in SSR environments). The one exception is tracker.group(), which always returns a valid group ID (with an _offline suffix) even before initialization.
import { tracker } from '@ndriadev/vite-plugin-monitor/client';tracker.init(userIdFn?)
Initialize the tracker. Safe to call multiple times — subsequent calls are no-ops (singleton).
tracker.init(() => getCurrentUserId());tracker.track(name, data?, opts?)
Emit a custom event.
tracker.track('button:clicked', {
buttonId: 'checkout-btn',
cartItems: 3,
});
// With explicit log level (default: 'info')
tracker.track('payment:failed', { code: 'CARD_DECLINED' }, { level: 'error' });
// Associate with a group
const groupId = tracker.group('checkout-flow');
tracker.track('checkout:started', {}, { groupId });
tracker.track('checkout:completed', { total: 99.99 }, { groupId });tracker.time(label) / tracker.timeEnd(label, data?, opts?)
Time a named operation. timeEnd emits a custom event with duration as a top-level field of the payload (not merged into data).
tracker.time('api:load');
const data = await fetchUserData();
tracker.timeEnd('api:load', { userId: data.id });
// Emits payload: { name: 'api:load', duration: 123, data: { userId: '...' } }tracker.setUser(userId, opts?)
Update the user identity after initialization. Emits a session:end event for the previous identity and a session:start event for the new one, both with trigger: 'userId-change'.
// After login
tracker.setUser(user.id, {
attributes: { plan: 'pro', role: 'admin' }
});
// After logout
tracker.setUser(null); // resets to anonymous IDtracker.setContext(attrs)
Attach arbitrary key-value metadata to every subsequent event.
tracker.setContext({
appVersion: '2.1.0',
region: 'eu-west',
featureFlags: ['new-checkout'],
});tracker.group(name)
Generate a unique group ID for correlating a sequence of related events. Unlike other tracker.* methods, this always returns a valid ID — even before tracker.init() is called (the ID will have an _offline suffix in that case).
const groupId = tracker.group('upload-flow');
tracker.track('upload:started', { fileName }, { groupId });
tracker.track('upload:completed', { bytes }, { groupId });tracker.destroy()
Emit session:end, flush the queue, remove all event proxies, and destroy the overlay.
tracker.destroy();💡 Usage Examples
Zero-Config Dev Setup
The minimal configuration for a productive local development setup:
// vite.config.ts
import { defineConfig } from 'vite';
import { trackerPlugin } from '@ndriadev/vite-plugin-monitor';
export default defineConfig({
plugins: [
trackerPlugin({
appId: 'my-app',
track: {
clicks: true,
http: true,
errors: true,
navigation: true,
},
dashboard: { enabled: true },
overlay: { enabled: true },
})
]
});Dashboard available at: http://localhost:5173/_dashboard
Log file written to: ./logs/my-app.log
HTTP Mode (Production)
// vite.config.ts
trackerPlugin({
appId: 'my-app',
storage: {
mode: 'http',
writeEndpoint: 'https://api.myapp.com/tracker/events',
readEndpoint: 'https://api.myapp.com/tracker',
pingEndpoint: 'https://api.myapp.com/health',
apiKey: process.env.TRACKER_API_KEY,
batchSize: 50,
flushInterval: 7000,
},
track: {
clicks: true,
http: true,
errors: true,
navigation: true,
console: false, // opt out in production if console has sensitive data
},
dashboard: {
enabled: true,
route: '/_dashboard',
auth: { username: 'admin', password: 'secret' },
includeInBuild: true,
pollInterval: 5000,
},
})WebSocket Mode
trackerPlugin({
appId: 'my-app',
storage: {
mode: 'websocket',
wsEndpoint: 'wss://api.myapp.com/tracker/ws',
pingEndpoint: 'https://api.myapp.com/health',
apiKey: process.env.TRACKER_API_KEY,
},
track: { clicks: true, http: true, errors: true, navigation: true },
})Your server must implement the tracker WebSocket sub-protocol. See WebSocket Protocol for the full message contract.
Custom User Identity
trackerPlugin({
appId: 'my-app',
track: {
clicks: true,
navigation: true,
// Function serialized and evaluated in the browser — must be pure (no closures)
userId: () => window.__auth?.userId ?? null,
},
})For identity that changes after page load (login/logout flows), use autoInit: false and call tracker.init() / tracker.setUser() manually:
// vite.config.ts
trackerPlugin({ appId: 'my-app', autoInit: false })
// In your app (e.g. auth store effect)
import { tracker } from '@ndriadev/vite-plugin-monitor/client';
authStore.subscribe((state) => {
if (state.isLoggedIn) {
tracker.init(() => state.userId);
} else {
tracker.setUser(null);
}
});Fine-Grained HTTP Capture
trackerPlugin({
appId: 'my-app',
track: {
http: {
captureRequestHeaders: true,
captureRequestBody: true,
captureResponseHeaders: false,
captureResponseBody: false,
excludeHeaders: ['x-internal-trace-id'],
redactKeys: ['fiscalCode', 'vatNumber', 'nationalId'],
maxBodySize: 4096,
ignoreMethods: ['OPTIONS', 'HEAD'],
ignoreUrls: ['/_dashboard', '/ping', '/health', /cdn\.myapp\.com/],
},
},
})Console Capture
trackerPlugin({
appId: 'my-app',
track: {
console: {
// Only capture errors and warnings
methods: ['error', 'warn'],
// Capture stack trace on console.error calls
captureStackOnError: true,
// Ignore noisy internal patterns (strings use strict equality, RegExp for fuzzy match)
ignorePatterns: ['[vite]', '[HMR]', '[tracker]', /^\[react-query\]/],
maxArgLength: 1024,
},
},
})Manual Initialization with Auth Gate
// vite.config.ts
trackerPlugin({
appId: 'my-app',
autoInit: false,
track: { clicks: true, http: true, errors: true, navigation: true },
})// App bootstrap (e.g. main.ts)
import { tracker } from '@ndriadev/vite-plugin-monitor/client';
async function bootstrap() {
const consent = await showConsentBanner();
if (consent.analytics) {
const user = await getSession();
tracker.init(() => user?.id ?? null);
}
}
bootstrap();Custom Events and Timers
import { tracker } from '@ndriadev/vite-plugin-monitor/client';
// Custom events
function onAddToCart(product) {
tracker.track('ecommerce:add-to-cart', {
productId: product.id,
productName: product.name,
price: product.price,
currency: 'EUR',
});
}
// Timed operations
async function loadDashboardData() {
tracker.time('dashboard:load');
try {
const data = await fetchData();
tracker.timeEnd('dashboard:load', { records: data.length });
return data;
} catch (err) {
tracker.track('dashboard:load-failed', { error: err.message }, { level: 'error' });
throw err;
}
}
// Correlated event sequences
async function runCheckout(cart) {
const groupId = tracker.group('checkout');
tracker.track('checkout:started', { itemCount: cart.items.length }, { groupId });
try {
const order = await submitOrder(cart);
tracker.track('checkout:completed', { orderId: order.id, total: order.total }, { groupId });
} catch (err) {
tracker.track('checkout:failed', { reason: err.message }, { groupId, level: 'error' });
}
}Log File Configuration
trackerPlugin({
appId: 'my-app',
logging: {
level: 'warn', // only warn and error events reach the log files
transports: [
{
format: 'json',
path: './logs/monitor.jsonl',
rotation: {
strategy: 'daily',
maxFiles: 7, // keep one week of archives
},
},
{
format: 'pretty',
path: './logs/monitor-human.log',
rotation: {
strategy: 'size',
maxSize: '20mb',
maxFiles: 3,
},
},
],
},
})Dashboard in Production Build
// vite.config.ts
trackerPlugin({
appId: 'my-app',
storage: {
mode: 'http',
writeEndpoint: 'https://api.myapp.com/tracker/events',
readEndpoint: 'https://api.myapp.com/tracker',
},
dashboard: {
enabled: true,
route: '/_dashboard',
includeInBuild: true, // copies dashboard/index.html into dist/_dashboard/
auth: { username: 'ops', password: 'secure-password' },
},
})After vite build, the dashboard SPA is present at dist/_dashboard/. Your reverse proxy or static file server must serve the dashboard index.html at /_dashboard (and all sub-paths, as a SPA fallback).
Note: You must run
pnpm build:dashboardbeforevite buildto generate the dashboard dist. If the dashboard dist is absent, the plugin logs a warning and skips the copy step.
📊 Dashboard
The dashboard is a standalone Vanilla TypeScript SPA injected into Vite's middleware at the configured route. It reads its own configuration from window.__TRACKER_CONFIG__ injected by the plugin at serve time.
Layout
┌─────────────────────────────────────────────────────┐
│ Header: Logo | Time Range Selector | Tab Switcher │
│ Backend status indicator │
├─────────────────────────────────────────────────────┤
│ [Metrics Tab] │
│ │
│ Row 1: Active Sessions | Total Events │
│ Unique Users | App Error Rate │
│ │
│ Row 2: Top Pages | Top App Errors │
│ Nav Funnel | Top Endpoints │
│ │
│ Row 3: Most Called EP | Avg HTTP | Error Rate │
│ Slowest EP │
│ │
│ Row 4: Total Requests | 2xx | 4xx | 5xx │
│ │
│ Row 5: Event Volume Chart | Error Rate % Chart │
├─────────────────────────────────────────────────────┤
│ [Events Tab] │
│ │
│ Events Table (filterable + full-text search) │
│ Event Detail Panel (opens on row click) │
└─────────────────────────────────────────────────────┘Time Ranges
Live (auto-polling) · 1h · 6h · 24h · 7d · 30d
In live mode the dashboard polls at pollInterval ms. For the other presets, data is fetched once and then refreshed only on manual navigation or filter change.
🔬 Debug Overlay
The overlay is a Shadow DOM–isolated floating widget rendered in the browser. It is mounted by the tracker client after tracker.init() when overlay.enabled: true.
Features
- Drag-and-drop by grabbing the header bar
- All four viewport corners selectable via
overlay.position - Keyboard shortcut:
Alt+Ttoggles open/close - Identity section: User ID (editable inline), Session ID (copy button), App ID
- Context section: Route (live-updated on open), Viewport size, Language, Connection type
- Open Dashboard — opens
window.location.origin + dashboard.routein a new tab - Remove Tracker Info — calls
overlay.destroy(), removes the overlay host element from the DOM. Automatic tracking continues running in the background — this does not calltracker.destroy() - Dark/light theme toggle, persisted in
localStorage
📡 API Contracts
Ingest Endpoint (HTTP)
Used when mode = 'http'.
Request (browser → server):
POST <writeEndpoint>
Content-Type: application/json
X-Tracker-Key: <apiKey> (only when apiKey is configured)
{
"type": "ingest",
"events": TrackerEvent[]
}Response: any 2xx is treated as success. Non-2xx causes the batch to be requeued and retried on the next flush interval.
External backend requirement: Every event in the ingest payload has
id: ""(an empty string set by the browser client). Your backend must assign a unique, non-emptyidto each event before persisting it — for example usingcrypto.randomUUID(), a MongoDB ObjectId, or a ULID. Theidfield is required by the dashboard to identify table rows without serializing the full event payload. The built-in middleware mode handles this automatically.
Read Endpoint (HTTP)
Used by the dashboard when mode = 'http'.
Request (dashboard → server):
GET <readEndpoint>?since=<ISO8601>&until=<ISO8601>
Accept: application/json
X-Tracker-Key: <apiKey> (optional)The dashboard always sends since and until. Your server must honour them and return only events whose timestamp falls within [since, until], sorted newest first.
Response:
{
"events": TrackerEvent[],
"total": 123,
"page": 1,
"limit": 5
}Gzip compression in middleware mode. The built-in
/_trackerread endpoint (used inmiddlewaremode) returns a gzip-compressed response (Content-Encoding: gzip, compression level 1). Browsers decompress it transparently. Custom backends (mode: 'http') are not required to compress — plain JSON responses are fully supported.
All further filtering (type, level, userId, full-text search) and all aggregations (charts, KPI cards, top lists) are performed client-side in the browser. Your server only needs to implement time-range filtering.
WebSocket Protocol
Used when mode = 'websocket'. All messages are JSON.
Authentication (when storage.apiKey is configured)
Immediately after the connection is established, the client sends an auth handshake as the first message:
Browser → Server:
{ "type": "auth", "key": "<storage.apiKey>" }Server → Browser:
{ "type": "auth_ok" }Until this handshake completes, the server must reject all other messages (recommended: close with code 1008 Policy Violation). If no apiKey is configured, no auth message is sent and the connection is immediately ready.
Event Ingest
Browser → Server:
{ "type": "ingest", "events": TrackerEvent[] }External backend requirement: Events arrive with
id: "". Your server must assign a uniqueidto each event before storing it (e.g.crypto.randomUUID()). See the Ingest Endpoint note for details.
Server → Browser (acknowledgement):
{ "type": "ack", "saved": 42 }Dashboard Query
Dashboard → Server:
{
"type": "events:query",
"reqId": "uuid-string",
"query": { "since": "2024-01-01T00:00:00.000Z", "until": "2024-01-02T00:00:00.000Z" }
}Server → Dashboard:
{
"type": "events:response",
"reqId": "uuid-string",
"response": { "events": TrackerEvent[], "total": 123 }
}Real-Time Push (optional)
Server → Browser:
{ "type": "push", "events": TrackerEvent[] }When the dashboard receives a push message while in Live mode, it merges the new events without a full re-query.
⚠️ Important Notes
Production Builds
mode: 'auto' without a writeEndpoint throws a configuration error at vite build time — by design. Always set storage.mode = 'http' (or 'websocket') explicitly for production builds.
autoInit: false and Proxy Installation
Even when autoInit: false, the setup script (which installs fetch, XHR, console, history proxies) is always injected at head-prepend. This is intentional: proxies must be active before any application code runs to capture events from the very first line. autoInit: false only delays the call to tracker.init(), not the proxy installation.
track.userId Must Be a Pure Function
The track.userId function is serialized to a string (.toString()) and injected into index.html. It must be a pure function with no closures over module-level variables at build time. To update user identity dynamically after initialization, use tracker.setUser().
Dashboard Auth is Client-Side Only
The dashboard.auth credentials are HMAC-hashed with appId and stored in window.__TRACKER_CONFIG__. This is suitable as a friction barrier in dev/staging environments. For production security, protect the dashboard route at the proxy/server level.
Dashboard Self-Exclusion
The dashboard route is automatically injected into ignoreRoutes for both the click tracker and the navigation tracker. Dashboard UI interactions are never self-tracked. Dashboard UI interactions are never self-tracked.
Endpoints Self-Exclusion
The tracker's own writeEndpoint, readEndpoint, pingEndpoint are automatically added to http.ignoreUrls to prevent infinite recursion (tracking the tracking requests).
includeInBuild Requires a Prior pnpm build:dashboard
The plugin's closeBundle hook copies the pre-built dashboard dist into the Vite output directory. If the dashboard dist is absent, the plugin logs a warning and skips the copy — it does not fail the build.
🔍 Troubleshooting
No Events Appearing in the Dashboard
- Check the browser Network tab: is the
POST <writeEndpoint>request succeeding (2xx)? - In middleware mode: is Vite still running? Restart and reload.
- Verify
track.clicks,track.http, etc. are enabled — all auto-trackers exceptconsoledefault tofalse. - Check the
track.levelfilter — events below the minimum level are silently discarded before enqueueing.
Dashboard Shows "Backend Offline"
- In middleware mode: the Vite dev server is the backend — make sure it is running.
- In HTTP mode: verify
storage.readEndpointandstorage.pingEndpointare reachable from the browser.
window.__TRACKER_CONFIG__ not found
The Vite dev server is not running or the page was opened without going through Vite. Always open the app through http://localhost:5173 (not directly as a file:// path).
autoInit: false — Tracker Calls Are Silently Dropped
The tracker proxy drops most calls until tracker.init() is called (the exception is tracker.group(), which always returns a valid ID). Ensure tracker.init() is called before any tracker.track() / tracker.setUser() calls in the application lifecycle.
Log Files Not Created
- Check that the Node.js process has write permission to the log directory.
- The directory is created recursively on
buildStart— avite buildrun (orvite devstart) is needed to trigger creation. - Check
logging.level— if set to'error'and no error events are tracked, the file may be empty but present.
HTTP Bodies Are [REDACTED]
The built-in redaction pipeline removes keys matching password, token, secret, card, cvv, iban, and several others. This is intentional and cannot be disabled. Add custom patterns via http.redactKeys.
🤝 Contributing
Contributions are welcome! Please open an issue first to discuss the change you have in mind.
# Clone and install
git clone https://github.com/nDriaDev/vite-plugin-monitor.git
cd vite-plugin-monitor
pnpm install
# Run tests
pnpm test
# Type-check
pnpm typecheck
# Lint
pnpm lint
# Start the dev server (dashboard + dev-server script)
pnpm dev:server # starts the mock event server on :4242
pnpm dev:ui # starts the dashboard on :5173📄 License
📞 Support
- Issues: GitHub Issues
- Email: [email protected]
If you find this plugin useful, please consider giving it a ⭐ on GitHub!
