🍬 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
FeatureDescription
πŸ›‘οΈ Safe serializationCircular refs, Error + stack + cause, Map/Set/Date/RegExp/BigInt, DOM nodes, throwing getters, depth + size caps
πŸ”’ Injection-safeEvery cell is built with textContent. Object keys, tag labels and action labels from untrusted data render as text
🚫 No inline handlersOne delegated listener and zero window.__* globals, so the panel works under script-src 'self'
πŸ“Š Table viewTime Β· Level Β· Tags Β· Message Β· Actions, with per-level filters and live counts
πŸ” Real searchDebounced search over the log data, so text inside collapsed objects still matches
πŸ“Œ PinsPinned 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 themesDark, light, or auto following prefers-color-scheme and updating live
β™Ώ AccessibleNamed buttons, aria-pressed toggles, a polite live region, focus rings, reduced-motion support
πŸ“± Mobile-readyFull-screen sheet under 640px; pointer-event dragging that works on touch
πŸ”Œ SinksPipe entries to a server, Sentry, or a test spy. The panel is just one consumer
πŸͺΆ Zero depsNo 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 enabled on 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 localStorage as rendered text so they survive a reload. Don't pin rows containing tokens or PII, or set persistPins: false. Raw arguments are never persisted, and the store is capped at 64 kB.
  • retainArgs is 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 %c styles 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 a TypeError into the calling code
  • Error objects render with message, stack, cause and 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, no window.__candy* globals
  • overrideConsole() is idempotent β€” StrictMode and HMR no longer stack panels or double logs
  • restoreConsole() can no longer leave console permanently 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 from localStorage
  • Search matches text inside collapsed objects (it searched the DOM before)
  • JSON keys and strings are actually highlighted; apostrophes no longer show as &#039;
  • 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 localStorage records are skipped individually instead of aborting the batch
  • showTimestamp, tags and theme: 'auto' now work (they were accepted and ignored)

Added

  • destroy() on the logger and the panel β€” removes DOM, listeners and stylesheet
  • captureConsole() β€” route console.* into an existing logger, returns a disposer
  • addSink() β€” pipe entries to a server, Sentry, or a test spy
  • createLogger(), 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, maxString options
  • CJS and IIFE builds; unpkg/jsdelivr now 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
  • maxLogs now bounds unpinned rows only
  • window.* globals moved to the CDN build only; the npm entry is sideEffects: false
  • Removed tableView (there was no alternative view) and the dead v1 terminal-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