Pages
Contents
@xpulse/debug β Component Concept
Status: STABLE Β· Updated March 2026 Parent:
GLOBAL_concept-ecosystem.md
Vision
@xpulse/debug is an optional, purely development-side package that
provides debugging tools for all layers of the xPulse ecosystem:
CLI commands, the template method {% debug() %}, collectors, a
debug toolbar, and a separate web profiler interface.
Installed only as a devDependency β not present in production,
all debug features therefore automatically unavailable.
Principle
| @xpulse/debug |
| βββ CLI Layer β debug:* commands for the terminal |
| βββ Template Layer β {% debug(variable) %} directly in templates |
| βββ Collector Layer β DataCollector base class + discovery |
| βββ Toolbar β slim browser bar with badges + link to profiler |
| βββ Web Profiler β separate interface with panels, sub-panels, areas |
Discovery Principle
@xpulse/debug follows the same discovery principle as @xpulse/cli (commands)
and @xpulse/controller (controllers) β no manual registration, no
registerPanel(), no event to subscribe to. The filesystem is the truth.
Collectors are automatically discovered from:
| 1. node_modules/@xpulse/debug/src/datacollectors/*.js β @xpulse/debug built-ins |
| 2. node_modules/@xpulse/*/src/datacollectors/*.js β other @xpulse packages |
| 3. src/datacollectors/*.js β the app itself |
Every @xpulse/* package provides its collector under src/datacollectors/ β
@xpulse/debug loads it when debug is installed. The package itself never imports
@xpulse/debug; it only lists it in optionalDependencies.
Styling
@xpulse/debug consistently uses the xPulse brand styles β identical
to xpulse-chat and xpulse-web. No independent design decisions.
CSS Custom Properties
| :root { |
| --bg: #0d0d0d; |
| --surface: #141414; |
| --border: #222; |
| --muted: #444; |
| --text: #c8c8c8; |
| --text-dim: #555; |
| --accent: #8703b0; |
| --accent2: #7eb8a4; |
| --danger: #c0606a; |
| --mono: 'JetBrains Mono', ui-monospace, 'Cascadia Code', 'Fira Code', Consolas, monospace; |
| --serif: 'Fraunces', Georgia, ui-serif, serif; |
| } |
Design Rules
border-radius: 2pxeverywhere β no rounded cornersborder: 1px solid var(--border)for all surfaces- Font:
var(--mono)for UI text,var(--serif)only for headings - Noise overlay:
body::beforewith inline SVGfeTurbulenceβ brand identity - Accent glow via
box-shadow: 0 0 0 1px rgba(135,3,176,0.35), ... - All colours from custom properties β no hardcoded hex in templates
Dependencies
| @xpulse/debug |
| βββ @xpulse/event β collectors listen via this.on() |
| βββ @xpulse/config β check debug.enabled |
| βββ @xpulse/router β register profiler routes |
| βββ @xpulse/template β panel rendering + register {% debug() %} |
| βββ @xpulse/cli β debug:* commands via autodiscovery |
No circular reference β @xpulse/debug depends on everything,
nothing depends on @xpulse/debug.
Activation
| // xpulse.json |
| { |
| "debug": { |
| "enabled": true, |
| "toolbar": true, |
| "web-profiler": { |
| "enabled": true, |
| "excluded-routes": ["/_debug/*"] |
| } |
| } |
| } |
| # .env.development |
| DEBUG=true |
@xpulse/debug is never active in production. The receipt automatically
creates .env.development with DEBUG=true.
Flag Overview
| Flag | Default | Description |
|---|---|---|
debug.enabled |
false |
Debug system on/off overall |
debug.toolbar |
true |
Inject toolbar into HTML responses |
debug.web-profiler.enabled |
true |
Register web profiler routes |
debug.web-profiler.excluded-routes |
["/_debug/*"] |
Routes that are not tracked |
toolbar: false is useful when you want to use the profiler only via URL
without seeing the toolbar in the browser β e.g. for API-heavy apps or
when the toolbar disrupts the layout.
CLI Layer
Commands under the debug: namespace, automatically discovered via @xpulse/cli.
| npx xpulse debug:events:list # all registered events with listener count |
| npx xpulse debug:config:show # loaded configuration (xpulse.json + .env) |
| npx xpulse debug:routes:list # registered routes with name + method |
| npx xpulse debug:packages:list # installed @xpulse/* packages with versions |
Template Layer β `{% debug() %}`
Custom template method that is automatically registered in
@xpulse/template when @xpulse/debug initialises. Only active when debug.enabled: true.
| {% debug(variable) %} |
| {% debug(config) %} |
| {% debug(_route) %} |
Outputs a formatted display in the browser β similar to Symfony's dump().
Type-specific rendering:
| Type | Rendering |
|---|---|
string |
Value with type label |
number / boolean |
Value with type label |
Array |
Expandable list with index + value |
Object |
Expandable tree with key + value, recursive (max. 3 levels) |
null / undefined |
Explicitly shown |
Error |
Formatted stack trace |
In production β empty string, no HTML, no overhead.
Collector Layer
Responsibility & Separation of Concerns
Each @xpulse/* package knows its own events best β therefore the
corresponding collector lives in the respective package under src/datacollectors/.
@xpulse/debug discovers and initialises these collector files but never
imports them directly.
Packages with collectors must list @xpulse/debug as optionalDependencies
β so the collector can resolve import { DataCollector } from '@xpulse/debug/collector'
when debug is installed:
| // package.json of an @xpulse/* package with a collector |
| "optionalDependencies": { |
| "@xpulse/debug": "^1.0.0" |
| } |
The ./collector subpath export is defined in @xpulse/debug/package.json:
| "exports": { |
| ".": "./src/index.js", |
| "./collector": "./src/collector.js" |
| } |
DataCollector Base Class
Every collector extends DataCollector β the base class handles
event registration, traceId assignment, panel JSON construction, and writing
to the trace cache. The collector itself only knows its own logic.
For collectors in external packages:
| import { DataCollector } from '@xpulse/debug/collector'; |
For collectors inside @xpulse/debug itself:
| import { DataCollector } from '../collector.js'; |
Concurrent Request Safety
Per-request state must be stored as a Map with traceId as the key β
never as a bare instance variable. Parallel requests would otherwise
overwrite each other:
| // β Wrong β breaks with concurrent requests |
| export default class MyCollector extends DataCollector { |
| _data = null; // β overwritten by the next request |
| async collect() { |
| this.on('my:event', (payload) => { this._data = payload; }); |
| this.on('http:response', () => { this.addArea({ value: this._data }); }); |
| } |
| } |
| // β Correct β each request has its own state |
| export default class MyCollector extends DataCollector { |
| _data = new Map(); // traceId β data |
| async collect() { |
| this.on('my:event', (payload, traceId) => { |
| this._data.set(traceId, payload); |
| }); |
| this.on('http:response', (payload, traceId) => { |
| this.addArea({ value: this._data.get(traceId) }); |
| this._data.delete(traceId); |
| }); |
| } |
| } |
Complete Example
| // src/datacollectors/HttpCollector.js |
| import { DataCollector } from '@xpulse/debug/collector'; |
| export default class HttpCollector extends DataCollector { |
| _reqs = new Map(); // traceId β { req, startedAt } |
| configure() { |
| this |
| .setName('http') |
| .setIcon('π') |
| .setPackage('@xpulse/http'); |
| } |
| async collect() { |
| this.on('http:request', (payload, traceId) => { |
| this._reqs.set(traceId, { req: payload, startedAt: Date.now() }); |
| }); |
| this.on('http:response', (payload, traceId) => { |
| const { status, duration } = payload; |
| const entry = this._reqs.get(traceId); |
| this._reqs.delete(traceId); |
| this.setBadge(`${status} Β· ${duration}ms`); |
| this.addArea({ |
| title: 'Request', |
| type: 'kv', |
| value: { |
| Method: entry?.req?.method ?? '-', |
| Path: entry?.req?.path ?? '-', |
| Status: status, |
| Duration: `${duration}ms`, |
| }, |
| }); |
| this.addSpan({ |
| kind: 'http', |
| label: 'http', |
| start: entry?.startedAt ?? (Date.now() - duration), |
| duration: duration ?? 0, |
| }); |
| }); |
| } |
| } |
DataCollector API
configure() β Fluent setup:
| Method | Description |
|---|---|
setName(name) |
Panel name + filename under packages/{name}.json |
setIcon(icon) |
Emoji icon for toolbar badge + sidebar |
setPackage(pkg) |
Package affiliation β for sidebar grouping |
collect() β Collecting data:
| Method | Signature | Description |
|---|---|---|
this.on(event, handler) |
(name, (payload, traceId) => void) |
traceId-aware event listener |
this.setBadge(text) |
(string) |
Badge text for toolbar |
this.addArea(area) |
(Area) |
Add a structured area to the panel |
this.addSubPanel(panel) |
({ title, areas[] }) |
Sub-panel with its own title |
this.addSpan(span) |
({ kind, label, start, duration }) |
Time span in the waterfall |
this.addTraceEvent(evt) |
({ name, ts, data }) |
Individual event in the events list |
Context β this._ctx:
Collectors have read access to the profiler context:
| Property | Type | Description |
|---|---|---|
this._ctx.config |
Config |
@xpulse/config instance |
this._ctx.router |
Router |
@xpulse/router instance |
this._ctx.controller |
Controller |
@xpulse/controller instance |
this._ctx.event |
Event |
@xpulse/event instance |
this._ctx.traces |
Map |
Active traces (traceId β trace object) |
this._ctx.version |
string |
@xpulse/debug version |
this._ctx.toolbarEnabled |
boolean |
Toolbar on/off |
this._ctx.profilerConfig |
object |
Resolved profiler configuration |
this._ctx.collectorCount |
number |
Number of successfully loaded collectors |
The base class handles internally:
- Calling
configure() - Calling
collect()and registering events - Registering the
http:responsewrite handler (aftercollect(), guaranteed last order) - Building panel JSON from
name,icon,package,badge,areas[],sub-panels[] - Writing panel JSON to
var/cache/debug/_{traceId}/packages/{name}.json
Area Types
| Type | Value Format | Description |
|---|---|---|
text |
string |
Plain text |
raw |
string | object |
JSON / code β formatted as <pre> |
kv |
{ key: value, ... } |
Key-value pairs stacked vertically |
table |
{ headers[], rows[][] } |
Table with header row |
list |
string[] |
Simple list |
badge |
{ label, value, status? }[] |
Status badges side by side |
timeline |
{ label, timestamp, duration? }[] |
Timeline |
logger-lines |
{ line, level, color }[] |
Coloured log lines (logger panel) |
Sub-panels
A collector can divide its panel into sub-panels β e.g. the TemplateCollector with one sub-panel per rendered template:
| this.addSubPanel({ |
| title: payload.view, |
| areas: [ |
| { title: 'Info', type: 'kv', value: { View: payload.view, Duration: `${payload.duration}ms` } }, |
| { title: 'Template Data', type: 'kv', value: payload.data }, |
| { title: 'Extends Chain', type: 'list', value: payload.chain.map(c => c.view ?? c) }, |
| ], |
| }); |
Sub-panels appear in the sidebar as indented links below the panel.
Panel JSON Format (Cache)
The JSON generated by the base class under packages/{name}.json:
| { |
| "panel-name": "http", |
| "icon": "π", |
| "package": "@xpulse/http", |
| "badge": "200 Β· 12ms", |
| "areas": [ |
| { |
| "title": "Request", |
| "type": "kv", |
| "value": { "Method": "GET", "Path": "/info", "Status": 200, "Duration": "12ms" } |
| } |
| ], |
| "sub-panels": [] |
| } |
Collectors by Package
Collectors live in their respective packages β @xpulse/debug contains
only the DebugCollector for profiler self-info:
| Package | Collector | Name | Icon | Listens to |
|---|---|---|---|---|
@xpulse/debug |
DebugCollector |
debug |
π | http:response (snapshot) |
@xpulse/http |
HttpCollector |
http |
π | http:request, http:response |
@xpulse/router |
RouterCollector |
router |
π | router:matched, router:not-found |
@xpulse/controller |
ControllerCollector |
controller |
βοΈ | http:request, controller:called |
@xpulse/template |
TemplateCollector |
template |
π | template:render:after, http:response |
@xpulse/event |
EventsCollector |
events |
β‘ | all events via event.emit intercept |
@xpulse/logger |
LoggerCollector |
logger |
π | logger:write, http:response |
@xpulse/config |
ConfigCollector |
config |
π§ | http:response (snapshot) |
@xpulse/dotenv |
EnvCollector |
env |
π | http:response (snapshot) |
@xpulse/theme has no collector β all theme events are
startup/lifecycle events that fire outside the request cycle.
EventsCollector β emit Intercept
The EventsCollector uses a special strategy: instead of hooking individual events,
event.emit is wrapped directly. This captures all events fired during a request β
including those not registered via event.register()
(e.g. template:render:after):
| const origEmit = event.emit; |
| event.emit = (name, data) => { |
| if (!SKIP.has(name)) { |
| const traceId = this._ctx.extractTraceId?.(data) ?? this._ctx.getCurrentTraceId?.(); |
| if (traceId) this._ctx.addEvent?.(traceId, { name, ts: Date.now(), data }); |
| } |
| return origEmit.call(event, name, data); |
| }; |
Excluded (SKIP): http:response:before (circular payload) and
logger:write (too many entries).
The panel shows two sections:
- Registered Events β all events known via
event.register()with listener count - Emitted During Request β all events actually fired during this request
Trace Cache Structure
| var/cache/debug/ |
| _{traceId}/ |
| trace.json β base: method, path, status, duration, spans[], events[] |
| packages/ |
| debug.json β DebugCollector |
| http.json β HttpCollector |
| router.json β RouterCollector |
| controller.json β ControllerCollector |
| template.json β TemplateCollector |
| events.json β EventsCollector |
| logger.json β LoggerCollector |
| config.json β ConfigCollector |
| env.json β EnvCollector |
| my-cache.json β app-own collector |
Cache management: The trace cache is cleaned up automatically β after each
request the oldest entries are deleted when more than 200 traces are present
(pruneOldTraces, fire-and-forget, never blocks the request).
Writing Your Own Collector
Any @xpulse/* package or the app itself places a collector under
src/datacollectors/:
| // src/datacollectors/MyCacheCollector.js |
| import { DataCollector } from '@xpulse/debug/collector'; |
| export default class MyCacheCollector extends DataCollector { |
| _hits = new Map(); // traceId β string[] |
| configure() { |
| this |
| .setName('my-cache') |
| .setIcon('πΎ') |
| .setPackage('my-app'); |
| } |
| async collect() { |
| this.on('cache:hit', (payload, traceId) => { |
| if (!this._hits.has(traceId)) this._hits.set(traceId, []); |
| this._hits.get(traceId).push(payload.key); |
| }); |
| this.on('http:response', (payload, traceId) => { |
| const keys = this._hits.get(traceId) ?? []; |
| this._hits.delete(traceId); |
| this.setBadge(`${keys.length} hits`); |
| this.addArea({ title: 'Cache Hits', type: 'list', value: keys }); |
| }); |
| } |
| } |
Toolbar
The debug toolbar is a slim, fixed bar at the bottom of the browser.
It is injected into every HTML response via http:response:before.
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β my-app Β· GET /info Β· 200 Β· 12ms β β‘ 18 β π 3 β Profiler β β |
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
Contents
| Section | Source |
|---|---|
| App name | config.name |
| Method + Path | trace.json |
| Status + Duration | trace.json |
| Badges | packages/*.json β badge field, only when set |
| Profiler link | Opens web profiler for this trace in a new tab |
Badges
Badges are dynamically read from the packages/*.json files of the current trace β
every panel that has a badge field set appears in the toolbar.
No hardcoding, no configuration needed. The title attribute of the badge span
shows the panel name as a tooltip.
Collapsing
State is stored in localStorage under xpulseProfilerCollapsed.
Collapsed: small tab button at the bottom right.
Web Profiler
The web profiler is a separate interface accessible via the toolbar. It opens in a new tab and displays all trace data in a structured view of panels and sub-panels.
| Click "Profiler β" in the toolbar |
| β |
| /_debug/web-profiler/{traceId}/summary/ |
Layout
| ββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ |
| β xPulse Profiler v1.0.0 trace abc123 β |
| βββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββββββββββββββββββββ€ |
| β β β |
| β @xpulse/debug β Panel content β |
| β Summary β ββββββββββββββββββββββββββββββββββββββββββββββ β |
| β Waterfall β Areas with structured data β |
| β @xpulse/http β β |
| β Http β β βββββββββββββββββββββββββββββββββββββββββββββββ β |
| β @xpulse/router β β Request [kv] β β |
| β Router β β Method GET β β |
| β @xpulse/controller β β Path /info β β |
| β Controller β β Status 200 β β |
| β @xpulse/template β β Duration 12ms β β |
| β Template β βββββββββββββββββββββββββββββββββββββββββββββββ β |
| β βΊ index β β |
| β βΊ base β β |
| β @xpulse/event β β |
| β Events β β |
| β @xpulse/logger β β |
| β Logger β β |
| β @xpulse/config β β |
| β Config β β |
| β my-app β β |
| β My Cache β β |
| βββββββββββββββββββββββ΄βββββββββββββββββββββββββββββββββββββββββββββββββββββ |
The sidebar is fully dynamic β built from the packages/*.json
files of the trace, grouped by the package field. Sub-panels appear as
indented entries. Panel names are displayed with an initial capital letter.
Panel Discovery
| GET /_debug/web-profiler/{traceId}/{panelId}/ |
| β |
| Profiler reads var/cache/debug/_{traceId}/packages/*.json |
| β |
| Builds sidebar dynamically from panel-name + package + sub-panels |
| β |
| Renders the active panel via @xpulse/template |
| β |
| Areas are rendered inline via renderArea() in profiler.js |
Special handling for summary and waterfall β these are rendered via their own
template files and always appear as the first entries under
@xpulse/debug in the sidebar.
Waterfall
The waterfall shows all spans of the request as horizontal bars with a time axis β making it immediately clear where time is being spent.
Structure:
- Time axis (ruler) at the very top β markers at 0, 25%, 50%, 75%, 100% of the total duration in ms
- Label column on the left (110px) β name of the span, with
titleattribute for long names - Bars β position and width proportional to the total duration
Sorting: Spans are sorted ascending by start time. When start times are equal, wider bars appear first (http before controller).
Colours by kind:
| Kind | Colour | Usage |
|---|---|---|
http |
Violet var(--accent) |
Entire HTTP request |
controller |
Mint var(--accent2) |
Dispatch time up to controller call |
template |
Purple #b06ad4 |
Template render duration |
router |
Blue #4a9eff |
Router matching |
other |
Grey var(--muted) |
Other |
Note on controller span time: The controller span measures the time from
http:request to controller:called β this includes routing and dispatch,
not the pure execution time of the controller handler. This is indicated in the panel
as Dispatch Time (request start β controller called).
Logger Panel
The logger panel shows all log entries of this trace:
| [2026-03-24T09:12:34.123Z] (info ) [http] GET /info 200 12ms |
| [2026-03-24T09:12:34.119Z] (debug) [controller] InfoController@index aufgerufen |
| [2026-03-24T09:12:34.115Z] (debug) [template] render info/index 4ms |
- Newest entries first β reverse chronological order
- Badge shows total count + warnings/errors:
12 Β· β 2 Β· β 1 - Level colours:
debugβ--accent2,infoβ--text,warnβ#c0a060,errorβ--danger
Template Structure
| @xpulse/debug/src/templates/web-profiler/ |
| dashboard.tpl.html β main layout: topbar + sidebar + content |
| toolbar.tpl.html β toolbar HTML (injected into app) |
| panels/ |
| summary.tpl.html β trace info + app name/ENV |
| waterfall.tpl.html β time axis + span bars |
| panel.tpl.html β generic panel: outputs areasHtml |
Area rendering is done not via separate partial templates, but via
renderArea() and renderAreaKv() etc. directly in profiler.js. This keeps
the template logic minimal and avoids many small files for trivial HTML.
Emitted Events
| Event | Payload | When |
|---|---|---|
debug:init |
{ enabled, profiler } |
Debug initialisation begins |
debug:ready |
{ enabled, profiler, routes } |
Debug ready |
Package Structure
| @xpulse/debug/ |
| src/ |
| collector.js β DataCollector base class (exported via ./collector) |
| index.js β init(), bootstrapDebug(), event.emit hook for logging |
| profiler.js β collector discovery, toolbar inject, routes, cache |
| commands/ |
| ConfigShowCommand.js |
| EventsListCommand.js |
| PackagesListCommand.js |
| RoutesListCommand.js |
| datacollectors/ |
| DebugCollector.js β profiler metadata (version, collectors, config) |
| template-methods/ |
| debug.js β {% debug() %} registration + type rendering |
| templates/ |
| web-profiler/ |
| dashboard.tpl.html |
| toolbar.tpl.html |
| panels/ |
| summary.tpl.html |
| waterfall.tpl.html |
| panel.tpl.html |
| receipt/ |
| up.js |
| down.js |
| test/ |
| package.json |
| xpulse.json |
The collectors of the other packages live in their respective packages:
| @xpulse/http/src/datacollectors/HttpCollector.js |
| @xpulse/router/src/datacollectors/RouterCollector.js |
| @xpulse/controller/src/datacollectors/ControllerCollector.js |
| @xpulse/template/src/datacollectors/TemplateCollector.js |
| @xpulse/event/src/datacollectors/EventsCollector.js |
| @xpulse/logger/src/datacollectors/LoggerCollector.js |
| @xpulse/config/src/datacollectors/ConfigCollector.js |
| @xpulse/dotenv/src/datacollectors/EnvCollector.js |
Open Items
| Topic | Status |
|---|---|
{% debug() %} type-specific rendering (expandable) |
Open |
Filter secrets in EnvCollector |
TBD β keys with SECRET, KEY, TOKEN, PASS |
Max. recursion depth for {% debug() %} |
TBD β 3 levels seems reasonable |
| Router span with real timing | TBD β router:matched payload has no start time |