Logging & Observability
Every class can write a tagged, level-gated log line, and the same call renders as a readable terminal line in development or one line of JSON for your log aggregator in production — you choose the shape once at boot, not at every call site. A logger is a plain object you construct in a field; it is not a DI provider, because the level and output format are process-wide settings, not per-instance dependencies.
import { Logger } from "@heximon/runtime";
export class UsersService {
private readonly logger = new Logger("UsersService");
public async create(input: CreateUser): Promise<User> {
this.logger.log("creating user");
// …
}
}
The label you pass ("UsersService") tags every line, so a log tells you which class wrote it without repeating
the name in each call:
[UsersService] creating user
Pick a level
A logger exposes these methods, coarsest to finest. Each forwards the message plus any extra arguments to the active backend:
| Method | Use it for |
|---|---|
error | A failure that needs attention. Always emitted. |
warn | A recoverable problem or a deprecation. |
success | A green ✔ milestone (a startup banner). Shown at log visibility despite the ✔ styling. |
log | The default informational line (a request served, a job done). |
debug | Detail you want while diagnosing, off in production. |
verbose | The finest tracing. |
this.logger.warn("retrying payment", { attempt });
this.logger.debug("cache miss", key);
A level is enabled together with every coarser level — turning on debug also emits log, warn, and
error. The finest enabled level sets the threshold.
Bind request context with child
A request handler often wants the same correlating fields — a method, a path, a request id — on every line it
writes. logger.child(fields) returns a new logger that merges those fields into every record it emits, so you
bind them once instead of passing them to each call:
import { type HttpAction, type Middleware, type Next } from "@heximon/http";
import { Logger } from "@heximon/runtime";
export class RequestLogMiddleware implements Middleware {
private static readonly logger: Logger = new Logger("RequestLogMiddleware");
public async handle(action: HttpAction, next: Next): Promise<Response> {
const { method, url } = action.request;
const visibility = action.scopes.includes("public") ? "public" : "guarded";
RequestLogMiddleware.logger
.child({ method, path: new URL(url).pathname, visibility })
.log("request");
return next();
}
}
On the pretty (terminal) reporter the bound fields render as a dim key=value continuation line beneath the
message:
[RequestLogMiddleware] request
method=GET path=/users visibility=guarded
The merge is cumulative — logger.child({ a }).child({ b }) binds both — and the nearer child wins a key clash,
so a request-scoped child can layer onto a service-scoped one.
Set the level and output shape
The active level and reporter are process-wide. Set both once with Logger.configure(...); every logger,
including ones constructed before the call, picks up the new settings on its next emit, so the order of
construction never matters.
import { Logger, LoggerReporter } from "@heximon/runtime";
Logger.configure({ level: { debug: true }, reporter: LoggerReporter.Json });
level is a set of booleans ({ debug: true } enables debug and coarser; { verbose: true } enables
everything). reporter selects the output shape:
| Reporter | Value | Output |
|---|---|---|
| Pretty (default) | LoggerReporter.Pretty | Human-readable terminal output, ANSI colors on a TTY. |
| JSON | LoggerReporter.Json | One JSON line per record ({ level, time, msg, tag, …fields }), Workers-safe. |
With no configuration the level falls back to the platform default — debug when the process is debugging,
informational log otherwise — and the reporter to pretty. Set HEXIMON_DEBUG=1 in the environment to flip the
default to debug without writing any config:
HEXIMON_DEBUG=1 pnpm dev
Logger.configure by hand. The generated boot wires it for you: when any class injects
CoreConfig, the host reads CoreConfig.logger (a { level?, reporter? } slot your app config layers in) and
calls Logger.configure once, right after the app is constructed.Structured JSON in production
The JSON reporter emits one line per record with a fixed, aggregator-friendly shape — the level name, an ISO
timestamp, the rendered message, the tag, and any bound child fields flattened alongside:
Logger.configure({ reporter: LoggerReporter.Json });
new Logger("UsersService").child({ requestId }).log("handling");
{ "level": "log", "time": "2026-06-14T10:12:00.000Z", "msg": "handling", "tag": "UsersService", "requestId": "01J…" }
It uses only web APIs (no node:util), so the same reporter runs unchanged on Node and on Cloudflare Workers,
where pretty colors are disabled automatically.
Ship logs to drains
A drain is a sink every record is fanned to in addition to the console output — OTLP, Sentry, Axiom,
a file, your own backend. The @heximon/logging package bridges
evlog's drain catalog onto the Logger: add its compiler plugin and name
the drains you want in heximon.config.ts. That block is the entire wiring — no application code:
import { defineHeximonConfig } from "@heximon/build";
import { HttpPlugin } from "@heximon/http/compiler";
import { LoggingPlugin } from "@heximon/logging/compiler";
export default defineHeximonConfig({
plugins: [new HttpPlugin(), new LoggingPlugin()],
logging: {
service: "orders-api",
environment: "development",
drains: ["memory"], // production: ["otlp", "sentry"] — each is zero-config via env vars
wideEvents: true,
},
});
Built-in drain names — otlp, axiom, sentry, datadog, posthog, better-stack, hyperdx, fs
(Node-only), memory (tests) — each map to an evlog adapter and read their own environment variables, so most
need no options. The build pulls in only the drains you named.
For a sink that needs an app-resolved dependency or a bespoke transport, implement the LogDrain interface
(emit(record) + optional flush()) and register it with Logger.addDrain:
import { type LogDrain, type LogRecord, Logger, type OnBootstrap, type OnShutdown } from "@heximon/runtime";
export class CaptureDrain implements LogDrain, OnBootstrap, OnShutdown {
public readonly records: LogRecord[] = [];
public onBootstrap(): void {
Logger.addDrain(this);
}
public onShutdown(): void {
Logger.removeDrain(this);
}
public emit(record: LogRecord): void {
this.records.push(record); // a real drain ships the record to your backend
}
}
A drain failure never breaks a request or recurses — the fan-out is fire-and-forget and fault-isolated.
Wide events
A wide event is one structured event per request, accumulated over its lifetime and emitted once at the
end — the low-volume counterpart to per-line logs. Turn it on with wideEvents: true (above), then add
request-scoped fields anywhere with logger.set(...):
import { Logger } from "@heximon/runtime";
import { type Controller, type Get } from "@heximon/http";
export class OrdersController implements Controller<"/orders"> {
private readonly logger: Logger = new Logger("OrdersController");
public list(_action: Get<"/">): { orders: string[] } {
const orders = ["ORD-1", "ORD-2"];
this.logger.set({ route: "list", resultCount: orders.length });
this.logger.log("listing orders"); // an ordinary line, still fanned to the drains
return { orders };
}
}
set is ambient — it writes the accumulator opened for the active request, so any service / command /
event handler called inside the request contributes fields with no threading, and it is a safe no-op outside a
request.
At request end one event is fanned to the drains carrying the set fields plus method, the request
path, status, durationMs, and requestId; its level is derived from the status (5xx → error, 4xx →
warn, else info).
The startup summary
The generated server logs two green-✔ success lines the moment it finishes building — one for the DI graph
(module + provider counts), one for the HTTP layer (controller count) — each with how long that span took:
✔ Heximon app started in 0.13ms (2 modules · 1 provider)
✔ Heximon server started in 0.67ms (1 controller)
Under pnpm dev the Vite host prints a matching compiled line at build time, so the three read as one
startup sequence:
✔ Heximon compiled in 44ms
✔ Heximon app started in 0.13ms (2 modules · 1 provider)
✔ Heximon server started in 0.67ms (1 controller)
The lines are on by default, emitted at the informational log visibility with consola's green ✔ success
styling — so they appear under the default level and a quieter level (warn/error) suppresses them, the same
as any other line. The counts are fixed at compile time; each elapsed span is measured at boot and formatted
sub-millisecond-aware (a sub-1ms span keeps two decimals, so a fast boot reads as 0.13ms, not 0ms).
The
two runtime lines work the same under Node, Nitro, and the edge; the compiled line is the dev/build host's, so
it appears under pnpm dev (and a bare vite build), not in a production runtime.
See also
- Request Context — read the correlation id off the ambient
Contextto bind onto a child logger. - Compiler Diagnostics — the other half of what you see in the terminal: how a build error points at the offending line.
- Error Handling — turn a thrown error into a clean response instead of a log-and-500.
- the ladder's L01 — Minimal — the
RequestLogMiddlewareabove, logging achild-bound line per request. - The flagship monolith — declarative
drains + wide events composed alongside the app's OTel / health / cache / saga / workflow legs; its logging e2e
registers a custom
LogDrainand asserts the one wide event a request produces.
Test app harness
createTestApp, TestApp, TestAppOptions, createTestClient, eagerInit, context.run, provider overrides, database isolation, contract-mode registration, multi-app isolation, heximon.config.ts plugin precedence
Context & Lifecycle
Request-scoped data with Context and the augmentable ContextData, deferred work via waitUntil and background, plus the OnInit, OnBootstrap, and OnShutdown lifecycle hooks, createApp, get, preload, and graceful shutdown with drain.