Heximon Logo
Essentials

Logging & Observability

Logger, child fields, Logger.configure, LoggerReporter pretty and json, log levels, HEXIMON_DEBUG, shipping logs to drains, opt-in wide events, and the startup summary lines.

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.

users.service.ts
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:

MethodUse it for
errorA failure that needs attention. Always emitted.
warnA recoverable problem or a deprecation.
successA green milestone (a startup banner). Shown at log visibility despite the styling.
logThe default informational line (a request served, a job done).
debugDetail you want while diagnosing, off in production.
verboseThe 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:

src/request-log.middleware.ts
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:

ReporterValueOutput
Pretty (default)LoggerReporter.PrettyHuman-readable terminal output, ANSI colors on a TTY.
JSONLoggerReporter.JsonOne 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
You rarely call 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:

heximon.config.ts
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:

capture.drain.ts
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(...):

orders.controller.ts
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 Context to 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 RequestLogMiddleware above, logging a child-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 LogDrain and asserts the one wide event a request produces.
Copyright © 2026