#Logging
Alepha provides structured logging via the $logger primitive from alepha/logger.
#Basic Usage
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 is the name of the $module the service belongs to, else MODULE_NAME
when it is set, else "app". Module names are what LOG_LEVEL filters on.
You can override the name:
1class App {2 log = $logger({ name: "Bootstrap" });3}
Every log entry carries an app field: alepha.name, which is APP_NAME when set, else the app name alepha build or alepha dev baked in. Set it to tell environments apart:
APP_NAME=my-app-production
This is useful for identifying logs from different applications in a shared logging system. Specs and unbuilt scripts, whose name is only the "app" fallback, leave the field out.
#Log Levels
The LoggerInterface exposes five methods:
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.
log.isLevelEnabled(level) answers whether a call at that level would be written, which is how to skip building an expensive payload nobody will see:
1if (this.log.isLevelEnabled("DEBUG")) {2 this.log.debug("Cache state", this.cache.dump());3}
#Configuration
#LOG_LEVEL
Set via the LOG_LEVEL environment variable. Case-insensitive.
# 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 (module_prefix=level also works) 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:
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)
#DEBUG
DEBUG is a shorthand following the debug package convention. It derives a level and sets the format to pretty, but only where LOG_LEVEL and LOG_FORMAT are unset:
DEBUG=1 # LOG_LEVEL=trace
DEBUG=alepha:* # LOG_LEVEL=alepha.*:debug,info
DEBUG=alepha:orm* # debug for alepha.orm and its submodules, info elsewhere
DEBUG=false and DEBUG=0 leave it off.
#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. Nothing implies it: an agent session passes
--verbose like anyone else.
--verbose does not touch LOG_LEVEL or LOG_FORMAT. It writes two store keys
that every Logger reads live: alepha.logger.level, which LOG_LEVEL seeds at
boot, and alepha.logger.format, an override of the formatter LOG_FORMAT
picked. Set them yourself to change the level (same syntax as LOG_LEVEL) or the
format of a running app:
1alepha.store.set("alepha.logger.level", "alepha.server:debug,info");
#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 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.
#Which stream: stdout or stderr
A server writes its log lines to stdout: they are its output, and that is where a container runtime or a log collector reads them.
A CLI writes them to stderr. As soon as CliProvider runs a command, or
prints help or a usage error, it switches its own container's logs over, so
stdout carries only what the command prints: --version, help, a rendered
result. That is what keeps a pipe honest:
alepha --version | cat # 0.29.0, and nothing else
lore quest list --output json | jq length
| Stream | In a CLI |
|---|---|
| stdout | print(): help, --version, a command's result |
| stderr | every log line: progress, warnings, and the reason for a failure |
The switch is the store key alepha.logger.stream, read by
ConsoleDestinationProvider each time it writes, and it is set on the CLI's
container only. An application container that a command boots in the same
process, the way alepha dev does, keeps writing to stdout.
A container that registers commands but is started with nothing to run (a server carrying a maintenance command) is not a CLI, and keeps stdout too.
#Log Entry Structure
Every log call produces a LogEntry:
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; // alepha.name, absent for the no-build "app" fallback 8 data?: unknown; // arbitrary payload or Error object 9 timestamp: number; // milliseconds since epoch10}
#Redaction
Credential-bearing values never reach a formatter, a destination, the breadcrumb
buffer or the devtools. Before an entry is built, every key of data, at any
depth, is compared with a short list, case-insensitively, ignoring - and _,
as a substring: authorization, cookie, password, passwd, secret,
token, apikey, accesskey, privatekey, credential and sessionid. A
match replaces the value with "[redacted]".
1this.log.debug("Outgoing call", {2 url: "https://api.example.com",3 headers: { "x-api-key": "sk_live_123", accept: "application/json" },4});5// data: { url: "...", headers: { "x-api-key": "[redacted]", accept: "application/json" } }
So refreshToken, set-cookie and X-Api-Key are all masked. An Error passed
as data is kept as is, since formatters need the instance, and a cycle becomes
"[circular]" rather than a crash. A secret under an innocent key ({ value: token })
is not detected: name the key for what it holds.
#Log Events
Every log call emits a "log:entry" 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:
1alepha.events.on("log:entry", (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.
1import { $hook, $inject } from "alepha"; 2import { LogBufferProvider } from "alepha/logger"; 3 4class ErrorReporter { 5 logBuffer = $inject(LogBufferProvider); 6 7 onError = $hook({ 8 on: "server:request:error", 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_LEVELare captured. Running atinfo, thedebugandtracecalls 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:
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:
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:
1import { Alepha } from "alepha"; 2import { 3 $logger, 4 LogDestinationProvider, 5 MemoryDestinationProvider, 6} from "alepha/logger"; 7 8class App { 9 log = $logger();10}11 12describe("$logger", () => {13 it("should log info message", ({ expect }) => {14 const alepha = Alepha.create({15 env: { LOG_LEVEL: "trace" },16 }).with({17 provide: LogDestinationProvider,18 use: MemoryDestinationProvider,19 });20 21 const output = alepha.inject(MemoryDestinationProvider);22 const app = alepha.inject(App);23 24 app.log.info("Test log message");25 26 expect(output.logs[0].message).toBe("Test log message");27 expect(output.logs[0].level).toBe("INFO");28 expect(output.logs[0].service).toBe("App");29 expect(output.wasLogged(/Test log/, "INFO")).toBe(true);30 });31});
wasLogged(pattern, level?) matches the message (a string is a substring, a RegExp is tested), never the formatted line, so a spec does not depend on LOG_FORMAT. The memory destination is a ring of 10,000 entries (maxEntries): once full, it drops the oldest fifth, and dropped counts entries it evicted, which tells "never logged" from "logged and pushed out".
#Custom Destination
Replace the log destination by substituting LogDestinationProvider:
1import { Alepha } from "alepha"; 2import { LogDestinationProvider } from "alepha/logger"; 3import type { LogEntry } from "alepha/logger"; 4 5class MyDestination extends LogDestinationProvider { 6 write(message: string, entry: LogEntry): void { 7 // send to external service, write to file, etc. 8 } 9}10 11const alepha = Alepha.create().with({12 provide: LogDestinationProvider,13 use: MyDestination,14});
#Custom Formatter
Replace the log formatter by substituting LogFormatterProvider:
1import { Alepha } from "alepha"; 2import { LogFormatterProvider } from "alepha/logger"; 3import type { LogEntry } from "alepha/logger"; 4 5class MyFormatter extends LogFormatterProvider { 6 format(entry: LogEntry): string { 7 return `[${entry.level}] ${entry.module}.${entry.service}: ${entry.message}`; 8 } 9}10 11const alepha = Alepha.create().with({12 provide: LogFormatterProvider,13 use: MyFormatter,14});