alepha@docs:~/docs/framework/guides/core$
cat 1-logging.md | pretty
5 min read
Last commit:

#Logging

Alepha provides structured logging via the $logger primitive from alepha/logger.

#Basic Usage

typescript
 1import { $logger } from "alepha/logger"; 2  3class UserService { 4  log = $logger(); 5  6  async createUser(name: string) { 7    this.log.info("Creating user", { name }); 8    // prints: [23:45:53.326] INFO <app.UserService>: Creating user {"name":"alice"} 9  }10}

$logger() returns a Logger instance. The logger name defaults to the class name. The module defaults to "app" (or the module name if the service belongs to a $module).

You can override the name:

typescript
1class App {2  log = $logger({ name: "Bootstrap" });3}

You can add an app field to all log entries by setting the APP_NAME environment variable:

bash
APP_NAME=my-app

This is useful for identifying logs from different applications in a shared logging system.

#Log Levels

The LoggerInterface exposes five methods:

typescript
1export interface LoggerInterface {2  trace(message: string, data?: unknown): void;3  debug(message: string, data?: unknown): void;4  info(message: string, data?: unknown): void;5  warn(message: string, data?: unknown): void;6  error(message: string, data?: unknown): void;7}

Severity order from lowest to highest: TRACE, DEBUG, INFO, WARN, ERROR, SILENT.

A log call is only written to the destination if its level is at or above the configured threshold. SILENT suppresses all output.

#Configuration

#LOG_LEVEL

Set via the LOG_LEVEL environment variable. Case-insensitive.

bash
# Global level
LOG_LEVEL=debug

# Per-module level with global fallback
LOG_LEVEL=alepha.core:trace,info

# Multiple module overrides
LOG_LEVEL=alepha.core:trace,alepha.server:debug,my.app:error,info

The syntax is module_prefix:level pairs separated by commas or semicolons, with an optional global level at the end. Module matching uses prefix matching: alepha matches alepha.core, alepha.server, etc.

Wildcard patterns are supported:

bash
LOG_LEVEL=alepha.*:debug,*.test:silent,info

Defaults by environment:

  • dev: info
  • prod: info (server) / warn (browser)
  • test: trace (but logs go to memory, only printed on test failure)

#LOG_FORMAT

Set via the LOG_FORMAT environment variable.

Value Description Provider
pretty Colored, human-readable output with timestamps, module and context PrettyFormatterProvider
cli Compact output for CLI sessions: HH:MM:SS L message {json} (no module/context) CliFormatterProvider
json Structured JSON, one object per line JsonFormatterProvider
raw Plain message text, no metadata (best for piping) RawFormatterProvider

If LOG_FORMAT is not set:

  • Production (non-browser): defaults to json
  • Everything else: defaults to pretty

The alepha and create-alepha CLIs default to cli. Pass --verbose to a CLI command to switch to pretty at trace level when you need module/context and the framework's internal logs. An agent session (Claude Code sets the CLAUDECODE env var) implies --verbose.

#Sub-process output

CLI tasks that shell out (yarn lint, vite build, nested alepha subcommands, …) only stream their output live when DEBUG or a more verbose level is enabled - i.e. under --verbose, CLAUDECODE, or LOG_LEVEL=debug. Below DEBUG (the default), that output is captured instead of streamed, so a quiet run such as alepha verify is not buried under thousands of sub-process lines. Captured output is still surfaced (stdout and stderr) if the task fails, and the Starting … / Finished … after Ns lines always print.

#Log Entry Structure

Every log call produces a LogEntry:

typescript
 1interface LogEntry { 2  level: "SILENT" | "TRACE" | "DEBUG" | "INFO" | "WARN" | "ERROR"; 3  message: string; 4  service: string; // class name, e.g. "UserService" 5  module: string; // module name, e.g. "app" or "my.project.users" 6  context?: string; // request-scoped correlation ID (from AsyncLocalStorage) 7  app?: string; // APP_NAME env variable 8  data?: unknown; // arbitrary payload or Error object 9  timestamp: number; // milliseconds since epoch10}

#Log Events

Every log call emits a "log" event on the Alepha event system, regardless of whether the message was above the configured threshold. This allows external listeners to capture all log activity:

typescript
1alepha.events.on("log", (event) => {2  // event.message  - formatted string (or undefined if below threshold)3  // event.entry    - the raw LogEntry4});

#Per-request breadcrumbs

Every HTTP request and every job run keeps its own bounded ring of log entries, so that when something throws you can ship the lines that led to it - not just the stack.

typescript
 1import { $hook, $inject } from "alepha"; 2import { LogBufferProvider } from "alepha/logger"; 3  4class ErrorReporter { 5  logBuffer = $inject(LogBufferProvider); 6  7  onError = $hook({ 8    on: "server:onError", 9    handler: ({ request, error }) => {10      sendToYourTool(error, {11        requestId: request.requestId,12        breadcrumbs: this.logBuffer.snapshot(),13      });14    },15  });16}

snapshot() returns the entries logged so far in the current context, oldest first, or undefined when no buffer is active. Two properties make it useful in production:

  • Entries below the active LOG_LEVEL are captured. Running at info, the debug and trace calls that explain the failure are still in the buffer even though they were never printed.
  • Values are already redacted. Credential-bearing keys are masked before the entry reaches the buffer, so a snapshot is safe to send off-box.

The ring keeps the last size entries. When it discards older ones, the snapshot opens with a WARN saying how many - a truncated buffer never passes itself off as complete.

Size is controlled by the alepha.logger.buffer atom:

typescript
1alepha.store.set("alepha.logger.buffer", { size: 200 }); // default: 50

Set size to 0 to disable capture entirely: no buffer is created and the write path becomes a no-op. Job runs use alepha.jobs.logMaxEntries instead, and persist their breadcrumbs onto the execution row when they fail.

To read the buffer somewhere other than an error hook - inside a handler, a middleware, another hook - inject LogBufferProvider and call snapshot() the same way. Outside any request or job, it returns undefined.

#Correlating with the client

request.requestId is the same value as the context field on every entry the request logged, and it is what the server returns in error responses. An id quoted by a user therefore finds their logs directly:

bash
grep '"context":"<the id they gave you>"' app.log

Put x-request-id (or x-correlation-id) on the request at your proxy and that id is used instead of a generated one, extending the correlation across services.

#Testing

In test mode, Alepha routes logs to MemoryDestinationProvider by default (unless LOG_LEVEL or DEBUG is set, which switches back to console output). Logs are buffered in memory and only printed to the console if a test fails.

To capture and assert on logs in tests:

typescript
 1import { Alepha } from "alepha"; 2import { 3  $logger, 4  LogDestinationProvider, 5  MemoryDestinationProvider, 6} from "alepha/logger"; 7  8class App { 9  log = $logger();10}11 12test("should log info message", ({ expect }) => {13  const alepha = Alepha.create({14    env: { LOG_LEVEL: "trace" },15  }).with({16    provide: LogDestinationProvider,17    use: MemoryDestinationProvider,18  });19 20  const output = alepha.inject(MemoryDestinationProvider);21  const app = alepha.inject(App);22 23  app.log.info("Test log message");24 25  expect(output.logs[0].message).toBe("Test log message");26  expect(output.logs[0].level).toBe("INFO");27  expect(output.logs[0].service).toBe("App");28});

#Custom Destination

Replace the log destination by substituting LogDestinationProvider:

typescript
 1import { LogDestinationProvider } from "alepha/logger"; 2import type { LogEntry } from "alepha/logger"; 3  4class MyDestination extends LogDestinationProvider { 5  write(message: string, entry: LogEntry): void { 6    // send to external service, write to file, etc. 7  } 8} 9 10const alepha = Alepha.create().with({11  provide: LogDestinationProvider,12  use: MyDestination,13});

#Custom Formatter

Replace the log formatter by substituting LogFormatterProvider:

typescript
 1import { LogFormatterProvider } from "alepha/logger"; 2import type { LogEntry } from "alepha/logger"; 3  4class MyFormatter extends LogFormatterProvider { 5  format(entry: LogEntry): string { 6    return `[${entry.level}] ${entry.module}.${entry.service}: ${entry.message}`; 7  } 8} 9 10const alepha = Alepha.create().with({11  provide: LogFormatterProvider,12  use: MyFormatter,13});