xPulse
πŸ‡¬πŸ‡§ EN

@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


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:

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:

  1. Registered Events – all events known via event.register() with listener count
  2. 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:

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

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
en/concept.md 2026-03-27