A logging library for Gleam with cross-platform support.
The name "birch" comes from birch trees, whose white bark gleams in the light.
Important
birch is not yet 1.0. This means:
- the API is unstable
- features and APIs may be removed in minor releases
- quality should not be considered production-ready
Before 1.0 we are exploring a broad set of features and stabilizing them at different rates, so 1.0 may ship with only a subset of what you see here. Once we reach 1.0, stable features will be ready for broad use.
We welcome usage and feedback in the meantime! We will do our best to minimize breaking changes regardless.
- Cross-platform: Works on both Erlang and JavaScript targets
- Zero-configuration startup: Just import and start logging
- Structured logging: Typed key-value metadata on every log message
- Multiple handlers: Console, file, JSON, or custom handlers
- Color support: Colored output for TTY terminals
- Lazy evaluation: Avoid expensive string formatting when logs are filtered
- Scoped context: Request-scoped metadata that propagates automatically
- Log rotation (experimental): Size-based and time-based rotation for file handlers
- Async handler (experimental): Non-blocking logging with buffered writes
- Sampling (experimental): Probabilistic sampling and rate limiting for high-volume scenarios
import birch as log
import birch/meta as m
pub fn main() {
// Simple logging
log.info("Application starting")
log.debug("Debug message")
log.error("Something went wrong")
// With typed metadata
let lgr = log.new("myapp")
lgr |> log.logger_info("User logged in", [
m.string("user_id", "123"),
m.string("ip", "192.168.1.1"),
])
}Tip:
import birch/meta as mkeeps metadata concise. All examples in this README usem.for brevity, butmeta.works identically.
Add birch to your gleam.toml:
[dependencies]
birch = ">= 0.1.0"Configure the default logger with custom settings:
import birch as log
import birch/level
import birch/meta as m
import birch/handler/console
import birch/handler/json
pub fn main() {
// Configure with multiple options
log.configure([
log.config_level(level.Debug),
log.config_handlers([console.handler(), json.handler()]),
log.config_context([m.string("app", "myapp"), m.string("env", "production")]),
])
// All logs now include the context and go to both handlers
log.info("Server starting")
}Change the log level at runtime without reconfiguring everything:
import birch as log
import birch/level
// Enable debug logging for troubleshooting
log.set_level(level.Debug)
// Later, reduce verbosity
log.set_level(level.Warn)
// Check current level
let current = log.get_level()Performance note (Erlang/BEAM):
set_level(),configure(), andreset_config()usepersistent_termfor storage, which triggers a global garbage collection across all BEAM processes on write. These functions are designed for infrequent configuration changes (application startup, debug toggling) rather than per-request use. In systems with many processes, frequent calls may cause latency spikes.
Create named loggers for different components:
import birch as log
pub fn main() {
let db_logger = log.new("myapp.database")
let http_logger = log.new("myapp.http")
db_logger |> log.logger_info("Connected to database", [])
http_logger |> log.logger_info("Server started on port 8080", [])
}Add persistent context to a logger:
import birch as log
import birch/meta as m
pub fn handle_request(request_id: String) {
let logger = log.new("myapp.http")
|> log.with_context([
m.string("request_id", request_id),
m.string("service", "api"),
])
// All logs from this logger include the context
logger |> log.logger_info("Processing request", [])
logger |> log.logger_info("Request complete", [m.int("status", 200)])
}Automatically attach metadata to all logs within a scope:
import birch as log
import birch/meta as m
pub fn handle_request(request_id: String) {
log.with_scope([m.string("request_id", request_id)], fn() {
// All logs in this block include request_id automatically
log.info("Processing request")
do_work() // Logs in nested functions also include request_id
log.info("Request complete")
})
}Scopes can be nested, with inner scopes adding to outer scope context:
log.with_scope([m.string("request_id", "123")], fn() {
log.info("Start") // request_id=123
log.with_scope([m.string("step", "validation")], fn() {
log.info("Validating") // request_id=123 step=validation
})
log.info("Done") // request_id=123
})- Erlang: Uses process dictionary. Each process has isolated context.
- Node.js: Uses AsyncLocalStorage. Context propagates across async operations.
- Other JS runtimes: Falls back to stack-based storage.
Check availability with log.is_scoped_context_available().
Six log levels are supported, from least to most severe:
| Level | Use Case |
|---|---|
Trace |
Very detailed diagnostic information |
Debug |
Debugging information during development |
Info |
Normal operational messages (default) |
Warn |
Warning conditions that might need attention |
Error |
Error conditions that should be addressed |
Fatal |
Critical errors preventing continuation |
Set the minimum level for a logger:
import birch as log
import birch/level
let logger = log.new("myapp")
|> log.with_level(level.Debug) // Log Debug and aboveThe default handler outputs to stdout with colors:
import birch/handler/console
let handler = console.handler()
// or with configuration
let handler = console.handler_with_config(console.ConsoleConfig(
color: True,
target: handler.Stdout,
))For log aggregation systems:
import birch/handler/json
let handler = json.handler()Output:
{"timestamp":"2024-12-26T10:30:45.123Z","level":"info","logger":"myapp","message":"Request complete","method":"POST","path":"/api/users"}Use the builder pattern to customize JSON output:
import birch/handler/json
import gleam/json as j
let custom_handler =
json.standard_builder()
|> json.add_custom(fn(_record) {
[
#("service", j.string("my-app")),
#("version", j.string("1.0.0")),
]
})
|> json.build()
|> json.handler_with_formatter()Note
File rotation is experimental and not planned for the 1.0 release.
Write to files with optional rotation:
import birch/handler/file
// Size-based rotation
let handler = file.handler(file.FileConfig(
path: "/var/log/myapp.log",
rotation: file.SizeRotation(max_bytes: 10_000_000, max_files: 5),
))
// Time-based rotation (daily)
let handler = file.handler(file.FileConfig(
path: "/var/log/myapp.log",
rotation: file.TimeRotation(interval: file.Daily, max_files: 7),
))
// Combined rotation (size OR time)
let handler = file.handler(file.FileConfig(
path: "/var/log/myapp.log",
rotation: file.CombinedRotation(
max_bytes: 50_000_000,
interval: file.Daily,
max_files: 10,
),
))Note
The async handler is experimental and not planned for the 1.0 release.
Wrap any handler for non-blocking logging:
import birch/handler/async
import birch/handler/console
// Make console logging async
let async_console =
console.handler()
|> async.make_async(async.default_config())
// With custom configuration
let config =
async.config()
|> async.with_queue_size(5000)
|> async.with_flush_interval(50)
|> async.with_overflow(async.DropOldest)
let handler = async.make_async(console.handler(), config)
// Before shutdown, ensure all logs are written
async.flush()For testing or disabling logging:
import birch/handler
let handler = handler.null()Create custom handlers with the handler interface:
import birch/handler
import birch/formatter
let my_handler = handler.new(
name: "custom",
write: fn(message) {
// Send to external service, etc.
},
format: formatter.human_readable,
)Handle errors from handlers without crashing:
import birch/handler
import birch/handler/file
let handler =
file.handler(config)
|> handler.with_error_callback(fn(err) {
io.println("Handler " <> err.handler_name <> " failed: " <> err.error)
})Avoid expensive operations when logs are filtered:
import birch as log
// The closure is only called if debug level is enabled
log.debug_lazy(fn() {
"Expensive debug info: " <> compute_debug_info()
})Log errors with automatic metadata extraction:
import birch as log
import birch/meta as m
case file.read("config.json") {
Ok(content) -> parse_config(content)
Error(_) as result -> {
// Automatically includes error value in metadata
log.error_result("Failed to read config file", result)
use_defaults()
}
}
// With additional metadata (using a named logger)
let lgr = log.new("myapp.db")
lgr |> log.logger_error_result("Database query failed", result, [
m.string("query", "SELECT * FROM users"),
m.string("table", "users"),
])Note
Sampling is experimental and not planned for the 1.0 release.
For high-volume logging, sample messages probabilistically:
import birch as log
import birch/level
import birch/sampling
// Log only 10% of debug messages
log.configure([
log.config_sampling(sampling.config(level.Debug, 0.1)),
])
// Debug messages above the threshold are always logged
// Messages at or below Debug level are sampled at 10%Use deterministic timestamps in tests:
import birch as log
let test_logger =
log.new("test")
|> log.with_time_provider(fn() { "2024-01-01T00:00:00.000Z" })Track which process/thread created each log:
import birch as log
let logger =
log.new("myapp.worker")
|> log.with_caller_id_capture()
// Log records will include:
// - Erlang: PID like "<0.123.0>"
// - JavaScript: "main", "pid-N", or "worker-N"2024-12-26T10:30:45.123Z | INFO | myapp.http | Request complete | method=POST path=/api/users
{"timestamp":"2024-12-26T10:30:45.123Z","level":"info","logger":"myapp.http","message":"Request complete","method":"POST","path":"/api/users"}For library code, create silent loggers that consumers can configure:
// In your library
import birch as log
const logger = log.silent("mylib.internal")
pub fn do_something() {
logger |> log.logger_debug("Starting operation", [])
// ...
}Consumers control logging by adding handlers to the logger.
On the Erlang target (BEAM), birch integrates with OTP's built-in :logger system. This section explains what happens automatically, how logs flow, and how to control the integration.
Note
This section only applies to the Erlang target. On JavaScript, birch uses its own handlers directly and :logger is not involved.
When you first log a message using birch's default configuration, birch automatically:
- Installs its formatter on
:logger'sdefaulthandler - Sends all birch LogRecords directly to
:logger(no birch handler is needed)
This means birch takes over formatting for the default :logger handler. All log output routed through that handler -- including OTP supervisor reports, application start/stop messages, and logs from other libraries -- will be formatted using birch's human-readable format.
2024-12-26T10:30:45.123Z | INFO | myapp | Application starting
2024-12-26T10:30:45.124Z | NOTICE | erlang | Application controller: app started
This happens lazily on first use via ensure_formatter_configured(), which is idempotent -- calling it multiple times is safe.
birch log (e.g., log.info("hello"))
|
v
logger:log(info, msg, #{birch_log_record => LogRecord})
|
v
ALL registered :logger handlers receive the event
|
+---> default handler --> birch formatter --> console output
+---> your_custom_handler --> its own formatter --> file/network/etc.
Key points:
- Birch logs flow through
:logger, not through birch handlers. The default configuration uses an empty handler list ([]) on BEAM. :loggercontrols routing and overload protection. Birch controls formatting.- OTP log events are also handled. When the
defaulthandler receives a non-birch log event (e.g., from a supervisor), the birch formatter builds a LogRecord from:loggerevent fields and formats it consistently. Structured reports use theirreport_cbcallbacks for human-readable output. - All
:loggerhandlers see birch logs. If you have added other:loggerhandlers (e.g., for log aggregation), they will receive birch log events too. Those handlers use their own formatters -- birch only modifies the formatter on thedefaulthandler.
Instead of relying on auto-configuration, you can set up the formatter explicitly:
import birch/erlang_logger
pub fn main() {
// Install birch formatter on the default :logger handler
let assert Ok(Nil) = erlang_logger.setup()
// ...
}Use setup_with_config to customize the formatter style:
import birch/erlang_logger
import birch/handler/console
// Use fancy style with icons
let assert Ok(Nil) =
erlang_logger.setup_with_config(console.default_fancy_config())If you have multiple :logger handlers, install birch's formatter on a specific one:
import birch/erlang_logger
import birch/formatter
// Install on a custom handler (e.g., a file handler you configured via :logger)
let assert Ok(Nil) =
erlang_logger.install_formatter_on("my_file_handler", formatter.human_readable)Restore OTP's default formatter:
import birch/erlang_logger
let assert Ok(Nil) = erlang_logger.remove_formatter()If you want birch to use its own console handler instead of going through :logger, configure explicit handlers. This bypasses the automatic :logger formatter installation:
import birch as log
import birch/handler/console
// Use birch's own console handler instead of :logger
log.configure([
log.config_handlers([console.handler()]),
])
// Logs now go through birch's handler, not :logger
// OTP logs will still use OTP's default formatterThis is useful when:
- You want birch output separate from OTP log output
- You want to avoid changing the formatter on the
default:loggerhandler - You are using another library that also configures
:loggerformatting
If your application already has :logger handlers configured (e.g., via Erlang's sys.config or programmatically):
- Other handlers keep their own formatters. Birch only modifies the
defaulthandler's formatter. Any handlers you've added (file handlers, remote syslog, etc.) are unaffected. - Birch logs appear in all handlers. Since birch sends logs via
:logger, all registered handlers receive them. Each handler applies its own formatter. - Level filtering still works. Both birch's level filter and
:logger's own level filters apply. A log must pass both to be output.
If you are using birch from an Elixir/Phoenix application:
- Birch's
ensure_formatter_configured()will override your:loggerformatter config. Phoenix configures:loggerformatting inconfig.exs. When birch's default config is first used, it replaces the formatter on thedefaulthandler. - To avoid this, use explicit birch handlers instead of the default config:
import birch as log
import birch/handler/console
// Skip :logger integration -- use birch's own handler
log.configure([
log.config_handlers([console.handler()]),
])- Alternatively, call
erlang_logger.setup()at application startup to make the override explicit and intentional rather than a side effect of the first log call.
Several logging libraries exist in the Gleam ecosystem. Here's how they compare:
| Feature | birch | glight | glogg | palabres |
|---|---|---|---|---|
| Erlang target | β | β | β | β |
| JavaScript target | β | β | β | β |
| Console output | β | β | β | β |
| File output | β | β | β | β |
| JSON output | β | β | β | β |
| File rotation | π§ͺ | β | β | β |
| Colored output | β | β | β | β |
| Structured metadata | β | β | β | β |
| Typed metadata values | β | β | β | β |
| Named loggers | β | β | β | β |
| Logger context | β | β | β | β |
| Scoped context | β | β | β | β |
| Lazy evaluation | β | β | β | β |
| Custom handlers | β | β | β | β |
| Sampling | π§ͺ | β | β | β |
| Stacktrace capture | β | β | β | β |
| Erlang logger integration | β | β | β | β |
| Wisp integration | β | β | β | β |
| Zero-config startup | β | β | β | β |
See DEV.md for development setup, testing, and contribution guidelines.
MIT License - see LICENSE for details.