Lightweight, signal-based web app monitoring library.
Captures FPS, JS heap, long tasks, Web Vitals, network requests, React renders, and custom events — all reactive via ssignal.
web-vitalsuseSignal, usePerformance, useNetwork, useReact, useEvents, useWebVitalsstart() is idempotent and stop() restores runtime patchesany in the public APInpm install monitor-api
import { createMonitor } from 'monitor-api'
const monitor = createMonitor()
monitor.start()
// Subscribe to FPS changes
monitor.performance.fps.subscribe((fps) => {
console.log('FPS:', fps)
})
// Subscribe to full performance snapshot
monitor.performance.snapshot.subscribe((snap) => {
console.log('Performance snapshot:', snap)
// { fps: 60, fpsHistory: [...], memory: { used: 45.2, total: 2048, percent: 2.2 }, ... }
})
createMonitor(config?)Creates and returns a Monitor instance. Does not start collecting — call monitor.start() explicitly.
import { createMonitor } from 'monitor-api'
const monitor = createMonitor({
collectors: ['performance', 'network', 'react', 'events', 'webVitals'], // default: all
sampleRate: 1, // per-monitor sampling probability from 0 to 1 (default: 1)
maxHistory: 120, // data points kept per metric; 0 disables history (default: 120)
networkFilter: (url) => !url.includes('analytics'), // optional
env: 'development', // 'development' | 'production' (default: 'development')
})
monitor.start() // start all collectors
monitor.stop() // pause (keeps data)
monitor.destroy() // stop + dispose all signals
monitor.start() is idempotent. Calling it more than once does not duplicate
event listeners, network patches, or React commit hooks.
monitor-api is designed to run in development, staging, and production browser
apps.
window is unavailable.monitor.start().monitor.stop() and monitor.destroy() restore patched browser APIs.maxHistory.monitor.start() and requires either
fetch or a custom transport.For production apps, prefer a conservative maxHistory, select only the
collectors you need, and use report.transform to send a compact payload.
Captures FPS, JS heap memory, Long Tasks, and Cumulative Layout Shift (CLS).
monitor.start()
// Granular signals — subscribe to only what you need
monitor.performance.fps.subscribe((fps) => {
console.log('Current FPS:', fps)
})
monitor.performance.memory.subscribe((mem) => {
if (mem) {
console.log(`Memory: ${mem.used}MB / ${mem.total}MB (${mem.percent}%)`)
} else {
console.log('Memory API not available (non-Chrome browser)')
}
})
monitor.performance.longTasks.subscribe(({ count, lastDuration }) => {
console.log(`Long tasks: ${count} total, last was ${lastDuration}ms`)
})
monitor.performance.cls.subscribe((cls) => {
console.log('Cumulative Layout Shift:', cls.toFixed(4))
})
// Or subscribe to the full snapshot
monitor.performance.snapshot.subscribe((snap) => {
console.log('Performance snapshot:', JSON.stringify(snap, null, 2))
/*
{
fps: 58,
fpsHistory: [60, 59, 58],
memory: { used: 45.2, total: 2048, percent: 2.2 },
memoryHistory: [2.1, 2.2, 2.2],
longTasks: { count: 3, lastDuration: 82.5 },
cls: 0.0023
}
*/
})
// Utilities
monitor.performance.clearHistory() // reset fpsHistory + memoryHistory
Snapshot shape:
interface PerformanceSnapshot {
fps: number
fpsHistory: number[]
memory: { used: number; total: number; percent: number } | null
memoryHistory: number[]
longTasks: { count: number; lastDuration: number | null }
cls: number
}
Note:
memoryisnullon non-Chrome browsers.actualDurationfor React components requires a dev build orreact-dom/profilingin production.
Intercepts fetch and XMLHttpRequest transparently. stop()/destroy()
restore the original fetch, XMLHttpRequest.prototype.open, and
XMLHttpRequest.prototype.send implementations.
Response bodies are never cloned or consumed for instrumentation. payloadSize
uses a valid Content-Length header when available; XHR can also use the
byteLength of an already-materialized ArrayBuffer. Otherwise it reports 0
to represent an unknown size.
While the collector is running, window5s updates when requests enter and leave
the five-second window. A one-shot timer targets only the next expiration; it is
released by stop(), destroy(), or clearLog().
monitor.start()
// Fire on every new request
monitor.network.onRequest.subscribe((entry) => {
if (!entry) return
console.log(`[${entry.initiator.toUpperCase()}] ${entry.method} ${entry.url}`)
console.log(` Status: ${entry.status} | Latency: ${entry.latency}ms | Size: ${entry.payloadSize} bytes`)
if (entry.error) console.warn(' Error:', entry.error)
})
// Full snapshot with rolling log + 5-second window metrics
monitor.network.snapshot.subscribe((snap) => {
const { window5s } = snap
console.log(`Last 5s: ${window5s.count} requests, avg latency ${window5s.avgLatency}ms, error rate ${(window5s.errorRate * 100).toFixed(1)}%`)
console.log('All entries:', snap.entries)
})
// Dynamic filter
monitor.network.setFilter((url) => !url.includes('/health'))
// Clear log
monitor.network.clearLog()
Entry shape:
interface NetworkEntry {
id: string
url: string
method: string // 'GET' | 'POST' | ...
status: number // 0 if network error
latency: number // ms
payloadSize: number // known response bytes; 0 when unknown
requestSize: number // request body bytes
initiator: 'fetch' | 'xhr'
timestamp: number // Date.now()
error: string | null
}
Hooks into window.__REACT_DEVTOOLS_GLOBAL_HOOK__ to capture React renders without touching the component tree.
Compatible with React 18 and React 19, dev and production builds.
monitor.start()
// Fire on every commit batch
monitor.react.onCommit.subscribe((entry) => {
if (!entry) return
console.log(`[React] ${entry.type} <${entry.component}> — ${entry.duration}ms`)
})
// Full snapshot with per-component aggregation over retained history
monitor.react.snapshot.subscribe((snap) => {
console.log(`Total commits: ${snap.totalCommits}`)
console.log(`Truncated commits: ${snap.truncatedCommits}`)
console.log('Slow components (>16ms):')
snap.slowComponents.forEach((e) => {
console.log(` <${e.component}> ${e.duration}ms [${e.type}]`)
})
console.log('By component:')
Object.entries(snap.byComponent).forEach(([name, stats]) => {
console.log(` ${name}: ${stats.renders} renders, avg ${stats.avgDuration}ms`)
})
})
// Adjust slow threshold
monitor.react.setSlowThreshold(8) // flag components slower than 8ms
monitor.react.clearLog()
Traversal is iterative and visits at most 10,000 fibers per commit by default.
Set maxFiberVisits to another limit (Infinity disables the cap). Commits that
reach the cap increment truncatedCommits. Fibers with actualDuration <= 0 are
ignored unless includeZeroDuration: true is configured. Unmounts come from the
dedicated React DevTools hook rather than private Fiber flags. They remain in
entries/onCommit, but do not inflate render aggregates or slowComponents.
Render entry shape:
interface RenderEntry {
component: string // displayName or function.name
duration: number // ms (actualDuration — 0 in prod without profiling build)
timestamp: number
type: 'mount' | 'update' | 'unmount'
commitId: number
}
Tip: profiling duration is unavailable in standard production builds. Use
react-dom/profilingfor production measurements, or opt into zero-duration entries explicitly when only component/phase information is needed.
Custom event bus. The app can emit events without importing the library.
Emitting events:
// Option A — import the helper
import { emitMonitorEvent } from 'monitor-api'
emitMonitorEvent('user:login', { userId: 42 })
emitMonitorEvent('route:change', { from: '/home', to: '/settings' })
emitMonitorEvent('error:caught', { message: 'Network timeout' })
// Or record directly through the typed monitor facade
monitor.events.emit('checkout:complete', { total: 49.99 })
// Option B — native CustomEvent (no import needed)
window.dispatchEvent(new CustomEvent('app:monitor:event', {
detail: { label: 'cache:miss', data: { key: 'user_profile' } }
}))
Subscribing:
monitor.start()
// Fire on each event
monitor.events.onEvent.subscribe((event) => {
if (!event) return
console.log(`[Event] ${event.label}`, event.data)
})
// Full snapshot with count by label
monitor.events.snapshot.subscribe((snap) => {
console.log('Event log:', snap.entries)
console.log('Counts by label:', snap.byLabel)
// { 'user:login': 3, 'route:change': 7, 'error:caught': 1 }
})
monitor.events.clearLog()
Event data is retained as a serializable copy. Malformed, circular, or oversized
data is recorded as null, while malformed events without a non-empty, bounded
string label are ignored. The defaults allow 256 characters per label, up to
5 nested object/array levels, and 16 KiB per payload; all limits can be
overridden in the collector config.
Collects standard Web Vitals metrics using the
web-vitals package:
CLS — Cumulative Layout ShiftFCP — First Contentful PaintINP — Interaction to Next PaintLCP — Largest Contentful PaintTTFB — Time to First Bytemonitor.start()
monitor.webVitals.onMetric.subscribe((metric) => {
if (!metric) return
console.log(`[Web Vital] ${metric.name}: ${metric.value} (${metric.rating})`)
})
monitor.webVitals.snapshot.subscribe((snap) => {
console.log('Latest CLS:', snap.cls)
console.log('Latest INP:', snap.inp)
console.log('Recent Web Vitals reports:', snap.entries)
})
monitor.webVitals.clearLog()
Metric shape:
interface WebVitalMetric {
name: 'CLS' | 'FCP' | 'INP' | 'LCP' | 'TTFB'
value: number
delta: number
rating: 'good' | 'needs-improvement' | 'poor'
id: string
navigationType: string
timestamp: number
}
CLS is unitless. FCP, INP, LCP, and TTFB are reported in milliseconds.
Subscribe to all collectors at once:
monitor.subscribe((snap) => {
console.log('Full monitor snapshot at', new Date(snap.timestamp).toISOString())
console.log(' FPS:', snap.performance.fps)
console.log(' Pending requests:', snap.network.entries.filter(e => !e.error).length)
console.log(' LCP:', snap.webVitals.lcp?.value ?? 'n/a')
console.log(' React commits:', snap.react.totalCommits)
console.log(' Custom events:', snap.events.entries.length)
})
// Or read synchronously
const snap = monitor.getSnapshot()
import { createMonitor } from 'monitor-api'
import { useSignal, usePerformance, useNetwork, useReact, useEvents, useWebVitals } from 'monitor-api/react'
const monitor = createMonitor()
monitor.start()
// Generic — subscribe to any signal
function FpsDisplay() {
const fps = useSignal(monitor.performance.fps)
console.log('Rendering FpsDisplay, fps =', fps)
return <span>FPS: {fps}</span>
}
// Collector-specific hooks
function PerfPanel() {
const { fps, memory, cls, longTasks } = usePerformance(monitor)
console.log('Rendering PerfPanel:', { fps, memory, cls })
return (
<div>
<p>FPS: {fps}</p>
<p>Memory: {memory ? `${memory.used}MB (${memory.percent}%)` : 'n/a'}</p>
<p>CLS: {cls.toFixed(4)}</p>
<p>Long tasks: {longTasks.count}</p>
</div>
)
}
function NetworkPanel() {
const { window5s, entries } = useNetwork(monitor)
console.log('Rendering NetworkPanel, requests in last 5s:', window5s.count)
return (
<div>
<p>{window5s.count} requests / 5s — avg {window5s.avgLatency}ms</p>
<ul>
{entries.slice(-5).map(e => (
<li key={e.id}>{e.method} {e.url} — {e.status} ({e.latency}ms)</li>
))}
</ul>
</div>
)
}
function ReactPanel() {
const { slowComponents, byComponent, totalCommits } = useReact(monitor)
console.log('Rendering ReactPanel, total commits:', totalCommits)
return (
<div>
<p>Total commits: {totalCommits}</p>
<p>Slow components:</p>
<ul>
{slowComponents.map((e, i) => (
<li key={i}>{e.component} — {e.duration}ms [{e.type}]</li>
))}
</ul>
</div>
)
}
function WebVitalsPanel() {
const { cls, inp, lcp } = useWebVitals(monitor)
return (
<div>
<p>CLS: {cls?.value ?? 'n/a'}</p>
<p>INP: {inp ? `${inp.value}ms (${inp.rating})` : 'n/a'}</p>
<p>LCP: {lcp ? `${lcp.value}ms (${lcp.rating})` : 'n/a'}</p>
</div>
)
}
const monitor = createMonitor({
env: 'production',
maxHistory: 60,
report: {
endpoint: 'https://my-api.com/metrics',
interval: 30_000, // send every 30s
headers: { Authorization: `Bearer ${token}` },
timeout: 5_000,
retry: {
maxAttempts: 3,
delay: (failedAttempt) => failedAttempt * 1_000,
},
transform: (snap) => ({
fps: snap.performance.fps,
memory: snap.performance.memory?.percent ?? null,
errorRate: snap.network.window5s.errorRate,
webVitals: {
cls: snap.webVitals.cls,
inp: snap.webVitals.inp,
lcp: snap.webVitals.lcp,
},
}),
},
})
monitor.start()
Production reporting is intentionally best-effort: failed report requests are
ignored after the optional retry policy is exhausted, so monitoring never breaks
the application. Errors from transform, serialization, timeout, and transport
setup are contained as well. The reporter keeps at most one delivery in flight
and skips interval ticks while it is pending. Authentication can be supplied
through headers. A custom transport({ endpoint, payload, body, headers, signal }) can replace fetch; without either transport, the reporter does not
start.
Without transform, the reporter sends a bounded, privacy-safe allowlist: the
snapshot timestamp; current FPS, memory percentage, long-task and CLS aggregates;
the five-second network aggregate; React commit counts; the retained custom-event
count; and Web Vital values, deltas, and ratings. It does not send request URLs,
errors, histories, event labels or data, component names, Web Vital IDs, or
navigation types. The reporter endpoint is also excluded from NetworkCollector,
while any configured network filter continues to apply.
transform is an explicit opt-in to a custom payload and receives the full
snapshot, including potentially sensitive application data. Redact secrets and
bound the returned payload before enabling it in production.
createMonitor({
// Enable only specific collectors
collectors: ['performance', 'network'],
// Or configure each individually
collectors: {
performance: true,
network: { filter: (url) => !url.includes('/analytics') },
react: {
slowThreshold: 8, // default 16ms
maxFiberVisits: 10_000, // default 10,000
includeZeroDuration: false, // default false
},
events: {
maxLabelLength: 256, // default 256 characters
maxDataDepth: 5, // default 5 nested object/array levels
maxDataBytes: 16 * 1024, // default 16 KiB of UTF-8 JSON
},
webVitals: { reportAllChanges: true },
},
maxHistory: 60, // data points per metric
sampleRate: 0.25, // one sampling decision per monitor instance
})
Collectors that are disabled—or belong to a sampled-out monitor—use inert facades with empty snapshots. Their concrete collectors are not constructed and their signals are not dependencies of the combined snapshot.
monitor-api/
├── dist/
│ ├── index.js ← ESM core
│ ├── index.cjs ← CJS core
│ ├── index.d.ts ← types
│ └── react/
│ ├── index.js ← React hooks (ESM)
│ ├── index.cjs ← React hooks (CJS)
│ └── index.d.ts
npm run typecheck
npm test
npm run build
npm run docs:build
npm run bench
docs:build generates TypeDoc HTML in docs/.bench builds the package and runs runtime benchmarks from bench/.BENCH.md.MIT — see LICENSE.
Repository: github.com/ElJijuna/monitor-api