π¬ Candy Logger
August 8, 2026 Β· View on GitHub
A browser console panel that can't crash your app β safe serialization, real stack traces, CSP-friendly, zero dependencies.
~6 kB gzipped core. The panel is a lazy chunk, so builds that never enable it ship almost nothing.
π Live Demo
β¨ Why
Most in-page loggers serialize your arguments with JSON.stringify. That throws on circular
objects, renders every Error as {}, and drops Map, Set, BigInt and Date on the floor.
Candy Logger uses a serializer that is total β it cannot throw, for any input.
const a = {}; a.self = a;
console.log(a); // β { "self": "[Circular]" }
console.error(new Error('boom')); // β Error: boom, with the full stack
console.log(new Map([['k', 'v']]), 1n); // β Map(1) contents, and 1n
| Feature | Description |
|---|---|
| π‘οΈ Safe serialization | Circular refs, Error + stack + cause, Map/Set/Date/RegExp/BigInt, DOM nodes, throwing getters, depth + size caps |
| π Injection-safe | Every cell is built with textContent. Object keys, tag labels and action labels from untrusted data render as text |
| π« No inline handlers | One delegated listener and zero window.__* globals, so the panel works under script-src 'self' |
| π Table view | Time Β· Level Β· Tags Β· Message Β· Actions, with per-level filters and live counts |
| π Real search | Debounced search over the log data, so text inside collapsed objects still matches |
| π Pins | Pinned rows stay on top, survive clear(), are exempt from maxLogs, and persist across reloads |
| π Format specifiers | %s %d %i %f %o %O %j %c %% applied the way browsers do |
| π Real themes | Dark, light, or auto following prefers-color-scheme and updating live |
| βΏ Accessible | Named buttons, aria-pressed toggles, a polite live region, focus rings, reduced-motion support |
| π± Mobile-ready | Full-screen sheet under 640px; pointer-event dragging that works on touch |
| π Sinks | Pipe entries to a server, Sentry, or a test spy. The panel is just one consumer |
| πͺΆ Zero deps | No runtime dependencies. Tree-shakeable, ESM + CJS + IIFE |
π¦ Install
npm install candy-logger
Or a single self-contained file, no build step:
<script src="https://unpkg.com/candy-logger@2"></script>
<script>
overrideConsole({ enabled: true });
</script>
π Quick start
Capture console.*
import { overrideConsole } from 'candy-logger';
// Idempotent β safe under React StrictMode and HMR.
const logger = overrideConsole({
enabled: import.meta.env.DEV, // or process.env.NODE_ENV !== 'production'
});
console.log('Hello World!');
console.info('User signed in', { userId: 123 });
console.error('Payment failed', new Error('CARD_DECLINED'));
Or use the logger directly
import { createLogger } from 'candy-logger';
const log = createLogger({ enabled: true });
log.log('App started');
log.info('Config loaded', config);
log.debug('Cache hit', { key });
log.success('Build passed!');
log.warn('Rate limit close');
log.error('Uncaught', err);
Shared instance
candy is always a real logger β never null β so importing it under SSR is safe.
It captures logs immediately; call attachUI() when you want to see them.
import candy from 'candy-logger';
candy.log('captured even with no panel');
candy.getLogs(); // β [LogEntry, β¦]
await candy.attachUI(); // panel appears, backfilled with everything above
π·οΈ Tagged logging
log.tagged({ label: 'AUTH', bg: 'rgba(139,92,246,.2)', color: '#a78bfa' },
'info', 'Token refreshed', { expiresIn: '1h' });
log.tagged([
{ label: 'DB', bg: 'rgba(234,179,8,.18)', color: '#eab308' },
{ label: 'SLOW', bg: 'rgba(239,68,68,.18)', color: '#f87171' }
], 'warn', 'Query took 3.1s', { query: 'SELECT * FROM orders' });
Tag colors are validated against a CSS property allowlist, so a color from an API response can never break out into markup.
βοΈ Configuration
createLogger({
enabled: true, // create the panel (alias: forceUI)
theme: 'auto', // 'dark' | 'light' | 'auto'
position: 'bottom-right', // 'bottom-right' | 'bottom-left' | 'top-right' | 'top-left' | 'full-bottom'
maxLogs: 500, // ring buffer for UNPINNED rows
showTimestamp: true, // show the Time column
tags: true, // show the Tags column
collapsed: false, // start minimized
dimWhenIdle: false, // fade to 12% until hovered
persistPins: true, // save pinned rows to localStorage
retainArgs: false, // keep references to logged objects (prevents GC)
maxDepth: 8, // object depth captured
maxString: 1000, // chars kept per string
defaultTags: [{ label: 'v2.1', color: '#7aa2f7' }],
badgeText: 'DEV',
actions: [{ label: 'Copy id', icon: 'π', onClick: (entry) => {} }],
});
Every option above does something. v2.0 accepted four (tableView, showTimestamp,
tags, theme: 'auto') that were silently ignored.
π― API
// Levels
log.log(...args) log.info(...args) log.debug(...args)
log.success(...args) log.warn(...args) log.error(...args)
log.tagged(tag | tag[], level, ...args)
// Data β works with or without a panel
log.getLogs(): LogEntry[]
log.getStats(): Record<LogLevel | 'all', number>
log.clear(options?: { includePinned?: boolean })
// Panel
await log.attachUI(options?)
log.detachUI()
log.showPanel() / log.hidePanel()
// Console capture
log.captureConsole(): () => void // returns a disposer
overrideConsole(options?): CandyLogger
restoreConsole()
isConsoleOverridden(): boolean
// Extensibility
log.addSink(sink: Sink): () => void
log.destroy() // removes panel, listeners, sinks; restores console
Sinks
The panel is one consumer of the store. Add your own:
const off = log.addSink({
write(entry) {
if (entry.level === 'error') {
navigator.sendBeacon('/api/logs', JSON.stringify(entry));
}
},
});
A sink that throws can never break the console.log call that triggered it.
Serializer
The serialization primitives are exported, in case you want them on their own:
import { safeStringify, normalize, serializeError, formatArgs } from 'candy-logger';
safeStringify(anythingAtAll); // { text, truncated } β never throws
serializeError(err); // { name, message, stack[], cause?, ...ownProps }
formatArgs(['%s: %d', 'hits', 42]);
πΌοΈ Framework examples
// React / Next.js β src/main.jsx or a client component
import { overrideConsole } from 'candy-logger';
overrideConsole({ enabled: import.meta.env.DEV });
// Vue β main.js
import { createApp } from 'vue';
import { overrideConsole } from 'candy-logger';
overrideConsole({ enabled: import.meta.env.DEV });
createApp(App).mount('#app');
// Angular β main.ts
import { overrideConsole } from 'candy-logger';
overrideConsole({ enabled: !environment.production });
<script>
import { onMount, onDestroy } from 'svelte';
import { createLogger } from 'candy-logger';
const log = createLogger({ enabled: true });
onMount(() => log.success('Ready!'));
onDestroy(() => log.destroy());
</script>
overrideConsole() is idempotent, so StrictMode's double-invoke and Fast Refresh
re-runs reuse the same logger instead of stacking panels.
π Security notes
- Don't ship the panel to end users. Gate
enabledon your dev flag. The logger keeps a buffer in memory; anything you log is readable by any script that can reach the instance. - Pinned rows are written to
localStorageas rendered text so they survive a reload. Don't pin rows containing tokens or PII, or setpersistPins: false. Raw arguments are never persisted, and the store is capped at 64 kB. retainArgsis off by default. Arguments are serialized once and then released, so the logger doesn't hold your objects (and their DOM subtrees) alive. Turn it on only if a custom action needs the live value.- Tag colors and
%cstyles are filtered through a CSS property allowlist.
π TypeScript
import type {
LogLevel, LogEntry, LogTag, LogAction,
CandyLoggerOptions, PanelPosition, PanelTheme, Sink,
} from 'candy-logger';
π οΈ Development
npm install
npm run build # tsup β ESM + CJS + IIFE, with .d.ts and sourcemaps
npm test # vitest (118 tests)
npm run typecheck
npm run demo # build, then serve demo-ui.html at :4321
π Changelog
v2.1.0 β correctness release
Fixed
console.log(circularObject)no longer throws aTypeErrorinto the calling codeErrorobjects render with message, stack,causeand custom fields instead of{}Map,Set,Date,RegExp,BigInt,Symbol, functions and DOM nodes all serialize- XSS via object keys, tag labels/colors, log levels and custom action labels
- Panel is fully functional under a strict CSP β no inline
onclick, nowindow.__candy*globals overrideConsole()is idempotent β StrictMode and HMR no longer stack panels or double logsrestoreConsole()can no longer leaveconsolepermanently hijacked- Level counters stay accurate past
maxLogs(previously drifted upward forever) - Pinned rows are exempt from eviction and no longer silently disappear
clear()keeps pinned rows instead of wiping them fromlocalStorage- Search matches text inside collapsed objects (it searched the DOM before)
- JSON keys and strings are actually highlighted; apostrophes no longer show as
' - Light theme defines its own level badge colors (contrast was ~1.6:1)
- Drag works on touch via pointer events, with on-screen clamping
- Corrupt
localStoragerecords are skipped individually instead of aborting the batch showTimestamp,tagsandtheme: 'auto'now work (they were accepted and ignored)
Added
destroy()on the logger and the panel β removes DOM, listeners and stylesheetcaptureConsole()β routeconsole.*into an existing logger, returns a disposeraddSink()β pipe entries to a server, Sentry, or a test spycreateLogger(),attachUI(),detachUI(),showPanel(),hidePanel(),isConsoleOverridden()- Console format specifiers:
%s %d %i %f %o %O %j %c %% - Sticky-bottom scrolling with a βN newβ pill instead of yanking you to the bottom
- Exported serializer primitives:
safeStringify,normalize,serializeError,formatArgs - Accessibility pass: accessible names,
aria-pressed, live region, focus rings, reduced motion - Mobile: full-screen sheet under 640px
retainArgs,persistPins,dimWhenIdle,maxDepth,maxStringoptions- CJS and IIFE builds;
unpkg/jsdelivrnow serve a working<script src>bundle - 118 tests
Changed
getLogs()/getStats()read from the core store, so they work with no panel- The panel is a dynamic import, so bundlers drop it from builds that never enable it
- The panel is opaque by default; the old 12%-until-hover behaviour is
dimWhenIdle: true maxLogsnow bounds unpinned rows onlywindow.*globals moved to the CDN build only; the npm entry issideEffects: false- Removed
tableView(there was no alternative view) and the dead v1terminal-ui.ts
Compatible β overrideConsole(), restoreConsole(), candy, CandyLogger, tagged(),
getLogs(), getStats() and every v2.0 option except tableView keep working. forceUI is
still accepted as an alias for enabled.
v2.0.0
Browser-only rewrite: table view UI, 6 levels, tags, action buttons, themes, JSON export.
v1.x (deprecated)
Node.js terminal support. Install candy-logger@1 if you need it.
π License
MIT
π€ Contributing
Contributions welcome. npm test must pass; new behaviour needs a test.
Made with π¬ by shehari007