Pages
Contents
Logger β Log Message Structure Concept
Status: CONCEPT Β· Created March 2026 Parent:
COMP_logger_spec.md,GLOBAL_concept-ecosystem.md
Why a dedicated concept?
Log output is the most important debugging surface in operation. Inconsistent formats cost time β you search, filter, grep, and still don't immediately understand what happened.
This concept defines how a log message is structured, which fields are mandatory, which are optional, and how the format looks depending on context (browser vs. server, development vs. production).
Anatomy of a Log Message
Every log message consists of at most five parts:
| [timestamp] (level) [module] message | key=value key=value |
| βββββββββββ βββββββ ββββββββ ββββββββ ββββββββββββββββββββββ |
| 1 2 3 4 5 (optional) |
| # | Part | Required | Description |
|---|---|---|---|
| 1 | timestamp |
yes | Timestamp β format depends on context |
| 2 | level |
yes | Log level β always 5 characters wide (padded) |
| 3 | module |
yes | Where the message comes from β package or feature |
| 4 | message |
yes | The actual message β short, precise |
| 5 | context |
no | Structured additional info as key=value pairs |
1. Timestamp
Server (Node.js)
ISO 8601, UTC, milliseconds:
| [2026-03-09T10:23:45.123Z] |
Machine-readable, sortable, timezone unambiguous. No local format.
Client (Browser)
Time only, no date β a browser window doesn't live for days:
| [10:23:45.123] |
Shorter, more readable in the browser console.
2. Level
Always 5 characters wide β padded with spaces on the right. This keeps all lines aligned:
| (debug) |
| (info ) |
| (warn ) |
| (error) |
Four levels, no verbose, no trace, no fatal β
deliberately minimalist.
| Level | Meaning | When |
|---|---|---|
debug |
Developer details | Normal flow, only visible in development |
info |
Relevant events | Start, stop, important state changes |
warn |
Unexpected but no abort | Configuration errors, fallbacks, degradation |
error |
Error that needs attention | Exceptions, failed operations |
No fatal β a fatal error throws an exception and terminates the process.
That doesn't need to be logged, it's immediately obvious.
3. Module
The [module] part identifies where the message comes from β
not what happened (the message itself handles that).
Convention
| [package-name-without-xpulse-prefix] |
| [feature-slug] |
Examples:
| [controller] β @xpulse/controller |
| [router] β @xpulse/router |
| [template] β @xpulse/template |
| [http] β @xpulse/http |
| [logger] β @xpulse/logger itself |
| [session] β @xpulse/session / feature |
| [presence] β feature in client |
| [webrtc] β feature in client |
| [app] β @xpulse/app / entry point |
Width
No fixed padding for the module β module names vary in length
and padding would look ugly for long names.
Instead: a consistent space after the closing ].
4. Message
The actual message. Lowercase, always English (Developer Layer β see GLOBAL_adr-013).
Rules
Short and concrete. A message describes an action or state β not a sentence, not an explanation, not context (that goes in the context part).
| β route matched |
| β controller discovered |
| β navItem registered |
| β connection timeout |
| β A new route was successfully matched and the handler was called |
Imperative or past participle β no questions, no exclamations:
| β server started |
| β config loaded |
| β request failed |
| β server has been started! |
| β could not load config? |
No values in the message β values belong in the context part:
| β [controller] navItem registered | key=info_team href=/info/team |
| β [controller] navItem info_team registered for /info/team |
This makes grep on the message possible without stumbling over values.
5. Context (optional)
Structured additional info as key=value pairs, separated by |.
| [2026-03-09T10:23:45.123Z] (info ) [http] request received | method=GET path=/info ip=192.168.1.0 |
Format Rules
- Separator:
|(space, pipe, space) between message and context block - Pairs:
key=valuewithout quotes unless the value contains spaces - Order: most important first
- No comma, no JSON, no semicolon β only spaces between pairs
| | method=GET path=/info/team duration=12ms status=200 |
| | key=info_team href=/info/team label="Team Nav" |
| | controller=InfoController method=team path=/info/team |
When to Use Context
Whenever a value is part of the event that you might want to filter
or search for later. Rule of thumb: if you would grep for a value,
it belongs in the context.
Full Examples
Server
| [2026-03-09T10:23:44.001Z] (info ) [app] server started | env=production port=3000 |
| [2026-03-09T10:23:44.012Z] (info ) [controller] ready | count=5 routes=8 |
| [2026-03-09T10:23:44.013Z] (info ) [controller] navigation ready | items=5 |
| [2026-03-09T10:23:45.001Z] (info ) [http] request received | method=GET path=/info ip=192.168.1.0 |
| [2026-03-09T10:23:45.008Z] (debug) [controller] controller called | controller=InfoController method=index |
| [2026-03-09T10:23:45.010Z] (debug) [template] render | view=info/index duration=2ms |
| [2026-03-09T10:23:45.012Z] (info ) [http] response sent | method=GET path=/info status=200 duration=11ms |
| [2026-03-09T10:23:46.001Z] (warn ) [controller] navItem duplicate key ignored | key=chat file=src/controllers/support/chat.js conflict=src/controllers/tool/chat.js |
| [2026-03-09T10:23:46.002Z] (error) [template] render failed | view=info/broken err=TemplateNotFound |
Client (Browser Console)
| [10:23:44.001] (info ) [session] login successful | user=alice |
| [10:23:44.120] (debug) [presence] peer connected | peerId=b3f7a |
| [10:23:44.300] (info ) [router] navigation | path=/peers |
| [10:23:48.001] (warn ) [webrtc] ice candidate missing | peerId=b3f7a |
| [10:23:55.120] (error) [session] decryption failed | key=chat_history |
Format by Context
| Server (Development) | Server (Production) | Client (Browser) | |
|---|---|---|---|
| Timestamp | [ISO 8601 UTC] |
[ISO 8601 UTC] |
[HH:MM:SS.mmm] |
| Level | (level) 5-char |
(level) 5-char |
(level) 5-char |
| Module | [module] |
[module] |
[module] |
| Message | text | text | text |
| Context | key=value |
key=value |
key=value |
| Colour | yes (ANSI) | no | yes (%c CSS) |
| Target | console + file | console + file | browser console |
| Min level | debug |
info |
debug / off |
JSON Format (optional, production)
For log aggregators (e.g. Loki, Elasticsearch) the server logger can
optionally output JSON β configurable via xpulse.json:
| { |
| "log": { |
| "format": "json" |
| } |
| } |
The logger then writes one JSON line per entry (NDJSON):
| {"timestamp":"2026-03-09T10:23:45.123Z","level":"info","module":"http","message":"response sent","context":{"method":"GET","path":"/info","status":200,"duration":11}} |
Fields: timestamp, level, module, message, context (object, not string).
Default is always human-readable text β JSON only when explicitly configured.
What `log.create(module)` Returns
The module slug is set once during create() and automatically
included in every message:
| const log = logger.create('controller'); |
| log.debug('navItem registered', { key: 'info_team', href: '/info/team' }); |
| // β [2026-03-09T10:23:44.001Z] (debug) [controller] navItem registered | key=info_team href=/info/team |
| log.warn('navItem duplicate key ignored', { key: 'chat', file: '...', conflict: '...' }); |
| // β [2026-03-09T10:23:44.002Z] (warn ) [controller] navItem duplicate key ignored | key=chat file=... conflict=... |
The second parameter log.debug(message, context?) is always a flat object β
no nested structures. Anyone needing to log deeply nested data
serialises it themselves and uses a single data=... key.
Separation from `@xpulse/debug`
@xpulse/logger writes log messages to console and file β
it is the writer.
@xpulse/debug listens via the logger:write event to every log entry
and collects it for the Web Profiler β it is the reader.
The format is identical. @xpulse/debug does not parse log messages β
it receives the structured data directly via the event:
| event.emit('logger:write', { |
| level: 'debug', |
| module: 'controller', |
| message: 'navItem registered', |
| context: { key: 'info_team', href: '/info/team' }, |
| timestamp: new Date().toISOString(), |
| }); |
This means: the logger concept remains cleanly separated from the debug visualisation.