@mars-tech/vite-debug-plugin

Vite plugin exposing a global $debug function in dev that mirrors output to the browser console and the Vite terminal. Calls are stripped from production builds.
1,430
Install
npm install @mars-tech/vite-debug-plugin
Latest Version:1.0.0
Vue:3
License:MIT
Last Updated:Oct 23, 2026
Links: GitLab  ·  npm
Maintainer: nicolas.mars

@mars-tech/vite-debug-plugin

🇫🇷 Version française : README-fr.md

Upgrading from 0.x? See MIGRATION.md.

npm version npm downloads pipeline status coverage report bundle size types Vite Node license

Vite plugin that exposes a global $debug function during development. Each call renders simultaneously in the browser console and the Vite dev server terminal, with consistent formatting and colors per severity. All $debug calls are stripped from production builds, including their argument expressions.

demo

Works across any Vite-powered framework. Verified end to end on Vue 3.5, React, Angular (via Analog) and Nuxt 4; SvelteKit, Astro and plain Vite SSR use the same Vite hooks and are expected to work, but are not part of the test matrix. Both client and server-rendered (SSR) code paths are supported.

Features

  • Dual output — every call renders in the browser console (styled) and the Vite terminal (ANSI), with per-level colors, tags, and source location.
  • Live browser inspection — objects, class instances, DOM nodes (click to Reveal in Elements), and Error stacks stay interactive in the console, just like console.log. See Live object inspection.
  • Vue DevTools — in Vue/Nuxt apps, every $debug also appears in a dedicated MARS-TECH timeline layer + inspector inside Vue DevTools, each entry carrying its payload and original source location. See Vue DevTools.
  • Zero production cost — all $debug(...) calls (including method and chained forms) are rewritten to void 0 at build and tree-shaken away; argument expressions never run.
  • SSR-ready — the global is installed on the server too, so router guards, store actions, and page setup can log during SSR.
  • Namespaces + runtime filtering — $debug.ns('auth') scoped loggers; toggle what shows at runtime via enable/disable/setLevel, a ?mt-debug= URL param / localStorage, or the MT_DEBUG env — no restart. See Runtime filtering & namespaces.
  • Secret redaction — mask tokens, emails, and custom patterns in output. See Redacting secrets.
  • Utility helpers — assert, time/timeEnd, count, group/groupEnd, table, dir. See Utility helpers.
  • Library authoring — library.debugBuild ships a dual variant so libraries log in dev and strip in build. See Library authoring.
  • First-class TS + ESLint — shipped /client global types (all module-resolution modes) and a /eslint flat config that declares the $debug global.

Contents

Quickstart

Requirements

Stack Versions tested
Vite 6.x · 7.x · 8.x
Node.js 20.19 · 22.12 · 24 (24 recommended; 20 is past end of life)
Vue 3.5+ (Composition + Options API, Pinia, Vue Router)
React 18+ (Redux Toolkit, React Router)
Angular 20+ via Analog (NgRx, Angular Router)
Nuxt 4.x (Pinia, route middleware)
SSR engines Vite SSR, Analog, Nitro/Nuxt, vite-node

See Framework recipes below for call-site snippets per stack.

1. Install and register the plugin

npm install -D @mars-tech/vite-debug-plugin

Register it in vite.config.ts:

// vite.config.ts
import { defineConfig } from "vite";
import vue from "@vitejs/plugin-vue";
import mtDebug from "@mars-tech/vite-debug-plugin";

export default defineConfig({
  plugins: [vue(), mtDebug()],
});

The default export is the plugin factory — works identically with import (ESM) and require() (CommonJS). It takes no arguments by default; every knob is in Plugin options.

Register mtDebug() unconditionally, in the top-level plugins array. It is not only dev tooling: during vite build it is what strips the $debug calls. Do not gate it on the command or mode (command === "serve" && mtDebug()) and do not set apply: "serve" — the build would then ship every call live, and each one throws in production. To turn the dev runtime off, use enabled: false, which still strips. Under build.rollupOptions.plugins, or in a Rollup or Rolldown config, the plugin only strips.

2. Enable the $debug global type

Add a triple-slash reference where your project's ambient types live (commonly src/vite-env.d.ts):

/// <reference types="@mars-tech/vite-debug-plugin/client" />

Module resolution must read the package exports map (moduleResolution: "bundler", "node16", or "nodenext"). On legacy "node10" / "classic", the package also ships a root-level client.d.ts that resolves by path — no change needed.

3. Tell ESLint about the global

TypeScript now knows $debug, but ESLint's no-undef rule doesn't read TS types — it will report '$debug' is not defined. Spread the shipped flat config to declare the global:

// eslint.config.js (flat config, ESLint 9 & 10)
import mtDebug from "@mars-tech/vite-debug-plugin/eslint";

export default [
  mtDebug, // declares $debug as a readonly global
  // ...your other configs
];

The /eslint export bundles no eslint dependency, so it never pins your version — the same import works on ESLint 9 and 10.

Your setup Import
Flat config (ESLint 9 & 10) the default export, spread as above
Custom globalName mtDebugFlatConfig({ globalName: "$log" })
.eslintrc (ESLint ≤9 only) { globals: mtDebugGlobals() } — the eslintrc system was removed in v10

TypeScript users can instead disable no-undef for .ts files — tsc already catches undefined names.

4. Call $debug in dev code

$debug("warning", new Error('Invalid variant "danger"'), {
  tag: "PropsValidator",
});

Output (both browser console and Vite terminal):

MARS-TECH Warning [PropsValidator] - Invalid variant "danger"

Framework recipes

$debug is a global. The call is identical in every framework — there is no adapter, no import, no per-framework API. The only thing that differs is where you register the plugin:

Stack Where mtDebug() goes
Vue / React / Svelte plugins: [] in vite.config.ts
Nuxt 4 vite: { plugins: [] } in nuxt.config.ts (see below)
Angular via Analog plugins: [] in vite.config.ts, alongside analog()
SvelteKit plugins: [] in vite.config.ts, alongside sveltekit()
// nuxt.config.ts — the one config that is not vite.config.ts
import mtDebug from "@mars-tech/vite-debug-plugin";

export default defineNuxtConfig({
  vite: { plugins: [mtDebug()] },
});

An idiomatic call site

Anywhere a framework gives you a hook with no good place to console.log, $debug fits — here a Vue props validator, which runs before the component renders:

<script setup lang="ts">
defineProps({
  variant: {
    type: String,
    required: true,
    validator(value: string) {
      const ok = ["primary", "secondary", "ghost"].includes(value);
      if (!ok) {
        $debug("warning", new Error(`Invalid variant "${value}"`), {
          tag: "PropsValidator",
          showSource: true,
        });
      }
      return ok;
    },
  },
});
</script>

Store actions, route guards, reducers and middleware all take the same call — see Namespaces and runtime filtering for keeping them separable once you have a lot of them.

The $debug API

Call signature and payload rendering

$debug<T>(
  level: 'error' | 'warning' | 'info' | 'debug' | 'success' | 'trace',
  payload: string | Error | T,
  options?: {
    showStack?: boolean
    showSource?: boolean
    tag?: string
    prefix?: string // override the rendered prefix for this call
    inspect?: boolean // force/suppress the live browser value for this call
  }
): void

// also variadic — log several values, or a printf format string:
$debug('info', label, user, el)            // space-joined + each stays live
$debug('info', '%s scored %d', name, score) // printf substitution

The table below is the one-line summary (used for the dev terminal and as the console label). In the browser, the raw value is also handed to the console so it stays live and inspectable — see Live object inspection.

Payload type Summary
string as-is
number / boolean String(value) — 42, true
null / undefined the literal words null / undefined
function name [Function] (anonymous [Function] when unnamed)
symbol Symbol(description)
Error error.message (stack appended when showStack: true; both redacted)
object / array JSON.stringify with circular guard, truncated at 500 chars (redacted first — one exception)
class instance ClassName { … }
Map / Set Map(n) / Set(n) with entries
RegExp /pattern/flags
Date ISO-8601 string
bigint 123n
Vue ref .value unwrapped, then standard rules apply
DOM Node <tag#id.class> summary
Promise [Promise] (not awaited)

Multiple values & printf

Pass more than one payload and they render like console.log(a, b, c) — summaries space-joined on one line, and in the browser each non-primitive stays live and inspectable:

$debug("info", "user", user, el); // "user { … } <div#app>" + live user + live node

Or lead with a printf-style format string and its substitution args. Unlike native console substitution, this is done by the plugin, so it works in the browser and the terminal:

$debug("info", "%s scored %d", name, score); // "Ann scored 42"

Supported specifiers: %s %o %O %j (stringify), %d %i (integer), %f (float), %c (consumed), %% (literal %). Leftover args are space-joined; a missing arg leaves its specifier literal. A trailing MTCallOptions-shaped object is still treated as options, so $debug("info", value, { tag: "x" }) keeps working.

Typos in that trailing object are not type errors — on the variadic form any object is a valid payload, so TypeScript cannot flag { tagg: "x" }. At run time an object counts as options only when every key is a recognised option; anything else is logged as a payload, which puts the typo in front of you instead of silently applying nothing. Use the two-argument form when you want autocomplete on options.

Utility helpers

$debug carries console-style helpers. Every one is stripped from production builds along with the base calls — including method ($debug.time(...)) and chained ($debug.ns('x')('info')) forms.

$debug.assert(user.id != null, "user must have an id", { tag: "auth" });
// logs at "error" level ONLY when the condition is falsy

$debug.time("render");
renderExpensiveThing();
$debug.timeEnd("render"); // → MARS-TECH Debug [render] - render: 12.4ms

$debug.count("api-call"); // → api-call: 1
$debug.count("api-call"); // → api-call: 2

$debug.group("checkout"); // real collapsible group in the browser
$debug("info", "validating cart"); // nested under the group
$debug("info", "charging card");
$debug.groupEnd();

$debug.table(rows, ["name", "total"]); // console.table in the browser
$debug.dir(document.body); // console.dir deep-object inspection
Helper Behavior
assert(condition, payload, opts?) Logs payload at error level only when condition is falsy.
time(label?) / timeEnd(label?) Logs elapsed ms between the two calls at debug level. Label defaults to default.
count(label?) Increments and logs a named counter at debug level.
group(label?) / groupCollapsed(label?) / groupEnd() Opens a real (optionally collapsed) console group in the browser; indents the dev-terminal output. The label is redacted like a message.
table(data, columns?) console.table in the browser; a text summary is sent to the terminal. Under redaction the browser prints the redacted summary instead.
dir(item, options?) console.dir deep-object inspection in the browser; a text summary is sent to the terminal. Under redaction the browser prints the redacted summary instead.

Every helper follows the runtime switches — disable(), setLevel, the prefix filter. For time/timeEnd and count the label doubles as the namespace, so a targeted filter such as -render mutes a time("render") pair; group/groupCollapsed, table and dir carry no namespace, so they answer to the global switches only (a -cart filter leaves them alone, disable() silences them). A suppressed group keeps its groupEnd paired, so nesting never drifts. Timing uses performance.now() when available.

count and time remember up to 1,000 labels each. Past that the least recently used label is forgotten — its count starts again at 1 and its timeEnd prints nothing — so a label built from data (count(item.id)) cannot grow memory for the life of the page. The group indent stops growing at 32 levels; groupEnd still closes every level.

Every helper above exists on every $debug — browser, SSR, the Vite process, the Vitest no-op stub, test: 'log', and the shims prepended to a brandPrefix build — so a call that works in a component cannot fail during server render or under test.

Namespaces and runtime filtering

Beyond the build-time levels / minLevel allowlist, $debug carries runtime controls so you can change what shows without editing config or restarting — and scope logs by namespace.

const auth = $debug.ns("auth"); // scoped logger, tags every call [auth]
auth("info", "login ok"); // → MARS-TECH Info [auth] - login ok
auth("debug", payload, { tag: "token" }); // namespace becomes "auth:token"

$debug.enable("auth:*,-auth:token"); // show auth:* except auth:token
$debug.disable(); // mute everything
$debug.enable(null); // drop the runtime override → back to the plugin's `filter` default
$debug.setLevel("warning"); // raise the floor (within the build set)
$debug.setLevel(null); // clear the runtime level override

The namespace is the tag — $debug("info", "x", { tag: "auth" }) and $debug.ns("auth")("info", "x") filter identically. With no filter set, every call shows (unchanged behavior).

Filter syntax (à la the debug package): comma/space-separated terms, * wildcard, leading - to exclude (exclusions win).

Where the filter comes from, highest priority first:

Surface Namespace filter Level floor
Browser ?mt-debug=auth:* URL param → localStorage["mt-debug"] ?mt-debug-level= → localStorage["mt-debug-level"]
SSR/Node MT_DEBUG=auth:* env MT_DEBUG_LEVEL=warning env

enable / disable / setLevel persist to localStorage in the browser (so the choice survives reloads); on the server they apply for the process lifetime. Runtime narrowing is bounded by the build-time level set — you can hide compiled levels, not resurrect stripped ones.

Prefix filtering

A second, orthogonal axis filters by the rendered prefix — the head of each line ([MARS-TECH]-style). With brandPrefix a prefix is a per-source identity, so this is how you mute a noisy library or isolate your app's own logs:

$debug.setPrefixFilter("MyApp"); // show only "MyApp"-prefixed logs
$debug.setPrefixFilter("-NoisyLib"); // hide everything from "NoisyLib"
$debug.setPrefixFilter(null); // clear → all prefixes pass

Same debug-style syntax as the namespace filter (*, comma/space-separated, leading - excludes). Sources, highest priority first:

Surface Prefix filter
Browser ?mt-debug-prefix=MyApp URL param → localStorage["mt-debug-prefix"]
SSR/Node MT_DEBUG_PREFIX=MyApp env
Build-time default mtDebug({ prefixFilter: "-NoisyLib" }) plugin option

Matched against each call's effective prefix (options.prefix if set, else the configured prefix). Under test: 'log', the test console logger honors it too. Caveat: utility helpers (time/count/group) always carry the configured prefix, so a library's helper output filters under the app prefix, not the library's.

Where output appears

Logged text is treated as untrusted. Any script running in the page during development can send log frames to the dev server, and server code may log request data. Before text reaches the terminal or the log file, its control characters — escape sequences, a bare \r, C1 controls, bidi overrides, the line and paragraph separators U+2028 / U+2029 — are shown escaped (\x1b]52;…, \x0d, \u202e, \u2028) instead of being acted on; tabs and newlines in a message are kept. A string that already carries ANSI colours (from chalk, say) prints its codes escaped too. Fields forwarded from the browser are also bounded: the message at 10 000 characters, tag and prefix at 256, the source file at 1 024. The message, tag and prefix are redacted before they are cut, because a cut can split a secret so that half of it escapes redaction. For the same reason a field too long to scan is replaced whole rather than cut: a stack over 16 384 characters by [stack omitted: N chars], a message over 65 536 (64 KiB) by [message omitted: N chars], a tag or prefix over 16 384 the same way. So is a field the browser would forward in more than 4 096 pieces: a stack with thousands of masked matches, a message of thousands of %d numbers.

Over time, too. A page script can send log frames for as long as its tab is open, as fast as the dev server takes them, over as many HMR connections as it opens. Each connection may forward a burst of 10 000 lines, then 2 000 a second, and every connection of the dev server together a burst of 20 000, then 4 000 a second: far more than an app logs, even from a few tabs. A frame over either is dropped before the plugin reads any of it; while a page floods, lines from your other tabs are dropped with its own. Where stdout does not make Node wait — a pipe, on macOS — and is not read as fast as lines come (vite | tee, a process manager), Node keeps the lines it has not written yet in memory: past 4 MiB of them, a forwarded line is left out of the terminal, and still written to the log file, which has a size limit of its own. Only the dev server's copy is limited: the browser console prints every call, and lines from SSR and the Vite process are not counted. When anything was dropped, one line per 10 s says how much:

[MARS-TECH] dropped 1240 browser log frames in the last 10 s: the page(s) sent more than 2000 a second on one connection, or 4000 in all.

Nothing is printed while nothing is dropped.

Live object inspection

The dev terminal can only show text, but the browser console can render live values. So in the browser $debug passes the raw payload to console as a trailing argument — you get the same interactivity as console.log(value):

  • objects / arrays — expandable tree, Copy and Store as global variable
  • class instances — constructor name, methods, prototype, getters
  • DOM nodes (document.querySelector(...)) — hover-highlight, click to Reveal in Elements; querySelectorAll NodeLists too
  • Error — real name + collapsible stack with clickable frames
  • Map / Set / Date / RegExp / typed arrays — native rendering

The one-line styled summary is still printed as the label, and the dev terminal still receives the (redacted) string form.

Control it:

  • Plugin option output.inspect (default true) — master switch for the whole app.
  • Per call { inspect: true } forces the live value even when output.inspect is off; { inspect: false } suppresses it for that call. (The pre-1.0 name { raw } still works, with a one-time console warning, until 2.0; inspect wins if both are set.)
  • Redaction interplay: when a RegExp redaction entry or redactDefaults is configured, no live value is appended — DOM nodes included, since a node's href, data-* attributes and an input's value render unmasked — so the redacted string form stands alone and secrets can't slip past redaction into the live object. Use { inspect: true } to override when you know a value is safe. String entries never reach the browser, so they do not change this.

Vue DevTools

In Vue and Nuxt apps, $debug registers a Vue DevTools plugin so every call also shows up inside Vue DevTools:

  • a custom inspector (the always-on view) — a browsable list of recent calls, each expandable to its level, message, payload, source location, and namespace. Look for the MARS-TECH entry (with its logo) in the Inspector list.
  • a MARS-TECH timeline layer — one event per call, each with its timestamp, level, source location and payload.

Both live in the plugin's own tab in the DevTools sidebar (the MARS-TECH logo), split into State (the inspector) and Timeline.

Vue DevTools: the MARS-TECH inspector fills in as the app logs, shows one call's detail, then the timeline records a $debug.ns("auth") call and a { tag: "checkout" } call

The timeline records only while armed. Open the MARS-TECH tab → Timeline → click the ● record button in the top-right corner. Until then DevTools drops every event and the view reads No events. The inspector needs no toggle.

Two things routinely make this look broken:

  • The sidebar's own Timeline tab never shows it. That tab has a hardcoded layer list — Mouse, Keyboard, Component events, Performance. No plugin's layer appears there, ours included. Use the MARS-TECH tab.
  • Recording resets every time DevTools opens. Vue DevTools registers plugins lazily and re-registering a layer forces its record flag back off, so the choice does not survive a reload. Re-arm it when you need it.

The About tab's view documentation link points at the documentation site.

It's wired through @vue/devtools-api, declared as an optional peer dependency. The integration is included only when that package resolves from the Vite root the way Vite resolves it — from a node_modules folder at or above the root; a copy only NODE_PATH or a global folder such as ~/.node_modules can reach does not count. Otherwise it is left out of the runtime and the console/terminal output is unaffected (with output: { devtools: true } written explicitly, the dev server says so each time it starts). Pinia or Vue Router may already bring it in, but under a strict layout such as pnpm's a transitive copy does not resolve from the root — install it explicitly to guarantee the panel, then restart the dev server:

npm install -D @vue/devtools-api

Turn it off with the output: { devtools: false } plugin option. Non-Vue frameworks are unaffected — they keep the full console feature set. (Note: this adds a DevTools presence; a browser page can't programmatically focus the DevTools tab, so there is no "click the console line to jump to the panel".)

$debug also exposes runtime controls (ns, enable, disable, setLevel, setPrefixFilter — see Runtime filtering & namespaces) and utility helpers (assert, time/timeEnd, count, group/groupEnd, table, dir).

SSR support

$debug is available inside server-rendered code — route guards, store reducers, page setup, server middleware. The plugin installs the global on both sides:

  • Browser — runtime injected via transformIndexHtml; logs to console.{error|warn|info|debug|log|trace} plus forwarded over the Vite WebSocket to the dev terminal.
  • Server — a Node runtime is auto-injected into every SSR-transformed module that references $debug (via the virtual module virtual:mt-debug/ssr). It writes ANSI-colored output directly to process.stdout. As a fallback for same-process SSR setups, the plugin also installs $debug on the Vite dev server's globalThis from configureServer.

In production builds (vite build, including SSR builds), every call site is stripped to void 0 regardless of side. No SSR runtime ships in the output.

Manual SSR opt-in

For worker-thread or separate-process SSR runners that don't share globalThis with Vite, import the virtual module from your server entry:

// e.g. src/main.server.ts (Analog) or a Nuxt server plugin
import "virtual:mt-debug/ssr";

The module is idempotent (if (!globalThis[GLOBAL_NAME])) and only loads in dev — strip removes any reference in production. It is served to the SSR pipeline only: imported from client code, it is an empty module. With string redact entries configured, a runner in a process of its own prints nothing: see Where string entries cannot reach.

NDJSON log file

Security: do not use output.file when your code logs secrets, even redacted ones. The log is a file on disk: any tool that reads files can read it, and the plugin's protections are a safety net, not a vault. Keep it for logs that hold no secrets.

For readers that are not a human at a terminal — an AI agent tailing what the app did, a test asserting on output — write every call to a file as well:

mtDebug({ output: { file: true } }); // .mt-debug/dev.ndjson under the Vite root
mtDebug({ output: { file: "logs/debug.ndjson" } }); // or a path, relative to the root or absolute
mtDebug({ output: { file: process.env.MT_LOG === "1" } }); // a toggle: false (or unset) is off
mtDebug({ output: { file: true, fileMaxBytes: 20 * 2 ** 20 } }); // stop at 20 MiB, not 100

One JSON object per line, typed as MTLogLine from @mars-tech/vite-debug-plugin/helpers:

{
  "level": "info",
  "message": "cart loaded",
  "tag": "cart",
  "source": { "file": "src/Cart.vue", "line": 12, "column": 3 },
  "prefix": "MARS-TECH",
  "t": "2026-09-15T10:00:00.000Z",
  "seq": 0,
  "origin": "browser"
}
  • One session per file — truncated when the dev server starts, closed when it stops. A restart (an edit to vite.config or .env) starts a new session; the server-side lines keep arriving after it.
  • At most 100 MiB a session, that last line included. Any script on the page can forge log lines as fast as the dev server takes them — one tab wrote 3 GB in 20 s. The first line that does not fit beside the room kept for a last one is replaced by that last line, at level warning and origin: "server", whose message says the limit was reached; that server writes nothing more to the file until it restarts, and the terminal gets one warning from it. The limit is measured on the file, not per writer: an old and a new server across a restart, or two dev servers on one config, share it, and in one process the file never goes past it (two processes on one file can each pass it by the line they were writing). A second writer can still add the lines that fit after the first one's last line, then a last line of its own, with a warning of its own. output.fileMaxBytes sets another limit, in bytes, and Infinity lifts it; null, like leaving it unset, keeps the default, and any other value that is not a number over 0 warns and keeps it.
  • Not opened under Vitest. A test run builds its server from the same vite.config, and used to truncate the log of a vite dev running beside it; with process.env.VITEST set the file is never opened. That includes a dev server a test starts itself — createServer() in a test, or one @nuxt/test-utils spawns — since it inherits the variable: to assert on its file, delete process.env.VITEST before it starts. Another tool that starts a dev server from the same config without VITEST — Storybook's Vite builder, say — still starts a new session in that file: switch it off there with a toggle (file: process.env.MT_LOG === "1"). Writes always append, so two writers never corrupt the file — they only interleave, each with its own run of seq.
  • Written after redaction — exactly what the terminal would print. The file is not a way around redact.
  • Kept from the page — a safety net, not a vault. Any script on the page can forge log lines, and reading the file back would tell it which of its guesses your string redact entries masked — an entry falls out one character at a time. So the plugin keeps the file from the page: it adds it to server.fs.deny, answers any HTTP request that reaches it with an empty 403 — by spelling and by device and inode, so case, the Unicode folding macOS applies, links, resolve.alias, base and publicDir lead nowhere — and refuses it in resolveId and load, ahead of Vite, in every environment, which covers the routes over the HMR WebSocket (fetchModule, ssrLoadModule, vite-plugin-inspect's RPC). The log is not opened at all, with one warning, where Vite or a tool would hand it out anyway: under server.fs.strict: false, in publicDir, next to a dev tool that reads any file for the page (vite-plugin-vue-devtools, Nuxt DevTools, Vite DevTools, devframes, TanStack Devtools), and next to @tailwindcss/vite unless the log sits in a directory of its own whose .gitignore holds * — the plugin writes one into .mt-debug/ (Tailwind's scan turns the files it reads into CSS the page fetches). @tailwindcss/postcss is not detected: give such a directory that .gitignore yourself. More generally, any tool that reads files from disk — Tailwind with an @source that reaches the log, a dev tool, an editor extension — can read the log too, wherever it is: that is beyond the plugin's reach. A log an earlier session left stays on disk: delete it before you turn such a tool on. Nuxt serves public/ through Nitro, outside Vite: keep the log out of Nuxt's public/. Vite reaches a file under the project in many ways; the plugin refuses every one known (the full list, and how each is checked, is in SECURITY.md), but a script running in your dev page may still find another. If your code logs secrets, even redacted, do not turn output.file on (see the warning above). A log you keep anyway is safer outside the project — outside server.fs.allow, which in a monorepo is the workspace root: Vite refuses every file there. And never put production keys in a dev setup. Run Vite 6.4.2, 7.3.2, 8.0.5 or later: older releases hand a page script any file, your .env included. A dev tool that reads any file for the page reads your .env and your vite.config the same way — a third-party problem; the plugin can only decline to add its log to it.
  • Browser calls arrive over the HMR channel, even with output.terminal: false. SSR calls are captured when the SSR runtime executes inside the Vite process (Vite SSR, vite-node); a separate SSR worker has its own process and is not seen.
  • origin tells them apart: "browser" or "server". seq orders lines within the session; t is the write time.
  • source is the location the terminal shows in (file:line:col): the file relative to the Vite root, with /, and a line and column counted from 1 — for browser and server lines alike, whether the transform stamped the call or the runtime read its stack. A script from another origin keeps its URL, and a file on another Windows drive stays absolute. A browser group() label, table() or dir() line has no source.
  • An unopenable path, or a value other than true, false, null or a path (0, {}, ""), warns and leaves the file off for the session — it never fails vite dev.
  • Fields are untrusted text from page code or request data. Control characters arrive already escaped, and the file stores DEL, C1, bidi and line-separator characters as JSON \u escapes (the parsed values are unchanged). Still escape a field before printing it to a terminal, and never treat it as instructions.
  • The plugin writes .mt-debug/.gitignore (*), so .mt-debug/ stays out of git without a word from you; a log in a directory of your own needs a .gitignore of yours. A file left by an earlier session is protected only while output.file names it — with enabled: false, and on a duplicate instance that stood down, too; delete it when you turn the option off.

Configuration reference

Plugin options

Every option, with its default — mtDebug() with no arguments is the same thing:

mtDebug({
  // levels and minLevel are alternatives; setting both warns and keeps levels
  minLevel: "trace", // or: levels: ["error", "warning"]
  output: {
    console: true,
    terminal: true,
    inspect: true,
    devtools: true, // written out, it warns when @vue/devtools-api is missing
    file: false,
    fileMaxBytes: 100 * 2 ** 20, // 100 MiB
  },
  showSource: true,
  prefix: "MARS-TECH",
  colors: {
    warning: { label: "Warning" },
  },
  globalName: "$debug",
  prefixFilter: "-NoisyLib",
  library: { debugBuild: false, brandPrefix: false },
  redact: [/x-api-key: \S+/, /sk_live_[A-Za-z0-9]+/],
  redactDefaults: false,
  test: "stub",
});
Option Type Default Notes
enabled boolean command === 'serve' Master switch, keyed off the Vite command (the dev runtime runs in vite dev; in vite build the plugin strips the calls instead) — not mode, so vite build --mode development stays inert. false turns the plugin off: no runtime served or injected, no DevTools pre-bundle, and test: 'log' falls back to the no-op stub; with output.file set, vite dev still refuses the log path to a page (a log an earlier session left). $debug calls are still stripped from your source, so disabled dev code cannot throw. true does not force the dev runtime into a production build; use library.debugBuild for that. Passing true explicitly changes nothing.
levels MTLevel[] all levels Allowlist. When both are set a non-empty levels wins and minLevel is ignored (with a startup warning); an empty levels: [] defers to minLevel.
minLevel MTLevel unset Threshold (trace < debug < info < success < warning < error).
output { console?, terminal?, inspect?, devtools?, file?, fileMaxBytes? } true but file Where output appears. console — the browser console; terminal — the Vite terminal (browser calls forwarded, SSR / Vite-process calls printed; false silences it everywhere, $debug stays defined); inspect — browser: also hand the raw payload to the console as a live, inspectable argument (per-call { inspect } overrides); devtools — Vue/Nuxt: surface each call in Vue DevTools (needs the optional peer @vue/devtools-api). With console, terminal and devtools all off and no file, a no-op $debug is served instead of the runtime. (Replaces the flat browser → console, terminal, inspect, devtools — all still work, with a warning, until 2.0.) file — also write every call as one NDJSON line (MTLogLine) to a file (not when your code logs secrets, even redacted: see NDJSON log file): true → .mt-debug/dev.ndjson under the Vite root, or a path; one session per file, written after redaction, browser calls included even with terminal: false (see NDJSON log file). fileMaxBytes — the most that file holds in one session, in bytes: 100 MiB by default, Infinity for no limit. See Where output appears.
showSource boolean true Show each call's file:line:col — the file relative to the Vite root, line and column counted from 1, the same on every surface; the per-call showSource option overrides it call by call. (Was showSourceLocation before 1.0.0 — the old name still works, with a warning, until 2.0.) For a call the dev transform stamps, the location is the original source — captured at transform time from the combined source map (works through .vue SFC compilation, on both client and SSR — Nuxt included). A call it cannot stamp falls back to the runtime stack: see a few lines off.
prefix string 'MARS-TECH' Output prefix.
colors MTColorOverrides unset Per-level color overrides; missing keys fall back to defaults.
globalName string '$debug' Identifier attached to globalThis. Must be a valid JavaScript identifier that is not a reserved word ($debug, myDebug, 日志); the plugin throws at startup otherwise.
prefixFilter string unset Build-time default prefix filter — mute/isolate logs by rendered prefix (debug-style syntax). Overridden by ?mt-debug-prefix= / localStorage / MT_DEBUG_PREFIX / $debug.setPrefixFilter(...). See Prefix filtering.
filter string unset Build-time default namespace filter, same debug-style syntax as $debug.enable() (cart*, -noisy, auth,cart:*). Overridden by ?mt-debug= / localStorage in the browser, MT_DEBUG on the server, and $debug.enable(pattern) at runtime; $debug.enable(null) returns to it. See Namespaces and runtime filtering.
showStack boolean false Default for the per-call showStack: attach the (redacted) Error stack to every call that carries an Error. { showStack: false } still opts a single call out.
library { debugBuild: boolean; brandPrefix?: boolean } — Library authoring. debugBuild: true makes vite build emit a dual output (dist/ stripped + dist/debug/ with calls kept, self-installing fallback prepended). brandPrefix: true additionally stamps this library's prefix onto its $debug calls so they stay branded inside a consuming app. (Replaces allowDebugBuild / libraryPrefix — both still work, with a warning, until 2.0.) See Library authoring.
redact (string | RegExp)[] [] Mask matches ([redacted], the whole match) in each string a call hands over — payloads, Error stacks, namespaces / tags, per-call prefixes, helper labels, source paths — and in the printed line. A RegExp applies on every surface — browser console, terminal, log file, Vue DevTools, test: 'log' — and is sent to the page as written: describe a secret's shape, never the secret. A string — or a number — matches literally, as written, on the server side only — terminal, log file, SSR output, test: 'log' outside Vitest browser mode — and is never sent to the browser; an empty or unset one is ignored. A RegExp is applied globally; keep text in front of a secret with a lookbehind. See Redacting secrets.
redactDefaults boolean false Also apply a curated secret set (JWTs, Bearer tokens, Authorization: Basic credentials, emails, Stripe-style keys, secret JSON values — as properties or [key, value] pairs). See Redacting secrets.
test false | 'stub' | 'log' 'stub' Under Vitest, what the plugin registers as a first setupFiles entry: 'stub' installs a no-op $debug (default global only); 'log' installs a real, config-aware console logger so component and test-file $debug(...) print with your configured prefix / globalName / levels and redaction; false registers nothing. (Replaces testStub / testLog — both still work, with a warning, until 2.0.) See Testing.

Color override merge

User-supplied colors deep-merges per level:

mtDebug({
  colors: {
    error: { label: "Error123" },
  },
});

Resolves to:

{
  error: { ansi: red, css: '#ef4444', label: 'Error123' },
  // other levels untouched
}

ansi applies on every terminal surface — with one requirement. Server-side output (SSR renders, $debug called from vite.config.ts or another plugin) comes from a generated runtime that cannot import your function. So the plugin calls each overridden ansi once, at config time, on a probe string, captures the escape codes it wraps around it, and ships those codes to the runtime. Any plain colour

Related Packages

vite-plugin-strip

Remove or strip specific code from your JavaScript or TypeScript.

4,734 1
uni-debug-toolbox

On-device debug toolbox for uni-app Vue 3

178 0
vite-plugin-vue-dev-locator

jump to local IDE source code while click the element of browser automatically.

31,227
vite-plugin-vue-inspector-ai

Vue Inspector with enhanced features - supports end line and clipboard copy

4,374 0
vite-plugin-click-to-vue-component

jump to local IDE source code while click the element of browser automatically.

685

Version History

Version Released Vue License
1.0.0 ^3.5.0 MIT
0.6.1 MIT
0.6.0 MIT
0.4.0 MIT
0.3.2 MIT
Pre-releases (1)
Version Released Vue License
1.0.0-rc.0 ^3.5.0 MIT