The basics
Logging
Oriel writes your app's log through channels: a file, a file per day, or stderr. Each entry is one line of JSON, written by pino.
Introduction#
Logging is organized in channels, as in Laravel. Each channel writes entries one way: the single channel appends
them to one file, the daily channel to a file per day, and the stderr channel to the process's standard error.
A stack channel sends each entry to several channels at once, and each channel keeps only the entries at or above
its level.
The channels are written with pino, a Node logger, which writes each entry as a line of JSON.
Configuration#
The channels are configured in config/. A new app's file:
import { dirname, join } from 'node:path'import { env } from '@orieljs/framework'const basePath = dirname(import.meta.dirname)const path = join(basePath, 'storage/logs/oriel.log')const level = env('LOG_LEVEL', 'debug')export default { default: env('LOG_CHANNEL', 'stack'), channels: { stack: { driver: 'stack', channels: String(env('LOG_STACK', 'single')).split(',') }, single: { driver: 'single', path, level }, daily: { driver: 'daily', path, level, days: Number(env('LOG_DAILY_DAYS', 14)) }, stderr: { driver: 'stderr', level }, mail: { driver: 'single', path: join(basePath, 'storage/logs/mail.log'), level }, null: { driver: 'null' }, },}default is the channel your entries go to: LOG_CHANNEL, stack unless you set it. The stack channel holds the
channels LOG_STACK lists, comma-separated: single in a new app, which writes storage/. LOG_LEVEL
sets the level of every channel, debug by default, so every entry is kept. The mail channel is there for the log
mailer: with MAIL_LOG_CHANNEL=mail in your .env, the mail it would have sent goes to
storage/ rather than to the default channel.
A channel name that channels doesn't define is an error when it is used: "Log channel [name] is not defined in
config('logging.channels').".
Available channel drivers#
| Name | What it does |
|---|---|
single | Appends every entry to the file path names, and creates its folder if needed |
daily | Writes to one file per day, oriel-2026-10-19.log for oriel.log, and keeps the files of the last days days (14 by default): each time it starts a day's file, it deletes older ones |
stderr | Writes to the process's standard error, for a container or a process manager to collect |
stack | Sends every entry to each of the channels it lists, in channels |
null | Discards every entry |
Each channel but stack and null takes a level. A driver that isn't one of these is an error when its channel
is used, "Log driver [name] is not registered.".
Building log stacks#
The stack driver combines channels, so one entry goes to all of them. Here the stack writes every entry to the
daily files, and errors and worse to stderr too:
import { dirname, join } from 'node:path'import { env } from '@orieljs/framework'const basePath = dirname(import.meta.dirname)const path = join(basePath, 'storage/logs/oriel.log')export default { default: env('LOG_CHANNEL', 'stack'), channels: { stack: { driver: 'stack', channels: ['daily', 'stderr'] }, daily: { driver: 'daily', path, level: 'debug', days: 14 }, stderr: { driver: 'stderr', level: 'error' }, },}A stack has no level of its own: each of its channels keeps the entries that meet its own level. A stack that lists itself, directly or through another stack, is an error, "Log channel [stack] includes itself.".
Log levels#
A channel's level is the least severe level it keeps. Oriel has the eight levels of RFC 5424, from the most severe:
emergency, alert, critical, error, warning, notice, info and debug. A channel at warning keeps
warning, error, critical, alert and emergency entries, and drops the others:
Log.debug('An informational message.')Log.emergency('The system is down!')With the stack above, the first entry is written to the daily file only, and the second to both channels. A level
that isn't one of the eight, such as pino's warn, is an error when the channel is made: "Invalid log level
[warn].".
Writing log messages#
The Log facade writes to the default channel, with a method for each level:
Log.emergency(message)Log.alert(message)Log.critical(message)Log.error(message)Log.warning(message)Log.notice(message)Log.info(message)Log.debug(message)Log.log('warning', message) takes the level as its first argument. The logger() helper writes a debug
entry, logger('Debug message'), and with no arguments returns the same log manager as the facade.
A controller logs as any other code does:
import { Log, view } from '@orieljs/framework'import { User } from '../../Models/User'import { Controller } from './Controller'export class UserController extends Controller { /** Show the profile for the given user. */ async show(id: string) { Log.info('Showing the user profile for user', { id }) return view('user.profile', { user: await User.findOrFail(id) }) }}Contextual information#
A method's second argument is the entry's context, an object written beside the message:
Log.info('User failed to login.', { id: user.id })The message is written as it is. Laravel replaces placeholders such as {id} in the message with the context's
values; Oriel keeps the context in its own fields, where log tools can search them. An Error in the context is
written as its name, message and stack.
Log.withContext() adds context to every entry written after it in the same request, whatever the channel. A
middleware can give each request an id:
import { randomUUID } from 'node:crypto'import { Log, withHeaders } from '@orieljs/framework'import type { HttpRequest, Middleware, Next } from '@orieljs/framework/contracts'export class AssignRequestId implements Middleware { async handle(request: HttpRequest, next: Next): Promise<Response> { const requestId = randomUUID() Log.withContext({ 'request-id': requestId }) const response = await next(request) return withHeaders(response, (headers) => headers.set('Request-Id', requestId)) }}The context belongs to the request: two requests running at once keep their own, and it is gone when the request ends. A console command and a queued job have a context of their own in the same way. Context added outside all of them, as in a service provider, reaches none of them.
Writing to specific channels#
Log.channel() writes to a channel other than the default one:
Log.channel('stderr').info('Something happened!')Log.stack() makes a stack of channels for one entry:
Log.stack(['single', 'stderr']).info('Something happened!')Laravel's Log::build(), which makes a channel from a configuration given at run time, isn't in Oriel: add the
channel to config/.
The log format#
Each entry is one line of JSON, as pino writes it. This is a line of storage/, which your app writes
and you don't type, for the controller above:
{"level":30,"time":1792387488361,"pid":39221,"hostname":"web-1","id":"7","rfc5424Level":"info","msg":"Showing the user profile for user"}msg is the message and rfc5424Level its level. time is in milliseconds since 1970, and pid and hostname
tell the processes apart. The context's keys sit beside them, id here. level is pino's number for the level:
20 for debug, 30 for info and notice, 40 for warning, 50 for error, and 60 for critical, alert and
emergency.
Reading the lines is up to the tools you use: tail -f storage/ follows the file, and log services
read JSON lines as they are. Laravel Pail, which tails the log in the terminal, isn't in Oriel.
Flushing the log#
pino writes in the background, so an entry may not be in the file yet when the line that logged it returns. Your app
writes out every waiting entry, and closes the files, when it shuts down: node oriel after its command finishes, and
the server when it receives SIGINT or SIGTERM (see Deployment). The log is closed after the
database, queue and cache connections, so an entry logged while they close is kept.
A script that makes the app itself must shut it down before it exits, or the entries still waiting are lost:
import { Log } from '@orieljs/framework'import { createApplication } from '../bootstrap/app'const app = await createApplication()Log.info('Report started')// ...await app.shutdown()Calling process.exit() before shutdown() has finished loses them too. To write the waiting entries out without
shutting the app down, await Log.flush(). A queued job or a command run by node oriel needs nothing:
the command shuts the app down when it ends.
Monolog channel customization#
Oriel doesn't use Monolog, so Laravel's tap, Monolog handlers, formatters and processors, and channels made by
your own factories, aren't in Oriel.