Skip to content

tylerbutler/birch

Folders and files

NameName
Last commit message
Last commit date

Latest commit

Β 

History

101 Commits
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

birch - logs that gleam ✨πŸͺ΅βœ¨

A logging library for Gleam with cross-platform support.

The name "birch" comes from birch trees, whose white bark gleams in the light.

Package Version Hex Docs

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.

Features

  • 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

Quick Start

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 m keeps metadata concise. All examples in this README use m. for brevity, but meta. works identically.

Installation

Add birch to your gleam.toml:

[dependencies]
birch = ">= 0.1.0"

Global Configuration

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")
}

Runtime Level Changes

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(), and reset_config() use persistent_term for 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.

Named Loggers

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", [])
}

Logger Context

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)])
}

Scoped Context

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
})

Platform Support

  • 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().

Log Levels

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 above

Handlers

Console Handler

The 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,
))

JSON Handler

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"}

Custom JSON Format

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()

File Handler

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,
  ),
))

Async Handler (experimental)

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()

Null Handler

For testing or disabling logging:

import birch/handler

let handler = handler.null()

Custom Handlers

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,
)

Error Callbacks

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)
  })

Lazy Evaluation

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()
})

Error Result Helpers

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"),
])

Sampling (experimental)

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%

Testing Support

Custom Time Providers

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" })

Caller ID Capture

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"

Output Formats

Human-Readable (default)

2024-12-26T10:30:45.123Z | INFO  | myapp.http | Request complete | method=POST path=/api/users

JSON

{"timestamp":"2024-12-26T10:30:45.123Z","level":"info","logger":"myapp.http","message":"Request complete","method":"POST","path":"/api/users"}

Library Authors

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.

BEAM Logger Integration

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.

What Birch Does on Startup

When you first log a message using birch's default configuration, birch automatically:

  1. Installs its formatter on :logger's default handler
  2. 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.

How Logs Flow on BEAM

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.
  • :logger controls routing and overload protection. Birch controls formatting.
  • OTP log events are also handled. When the default handler receives a non-birch log event (e.g., from a supervisor), the birch formatter builds a LogRecord from :logger event fields and formats it consistently. Structured reports use their report_cb callbacks for human-readable output.
  • All :logger handlers see birch logs. If you have added other :logger handlers (e.g., for log aggregation), they will receive birch log events too. Those handlers use their own formatters -- birch only modifies the formatter on the default handler.

Controlling the Formatter

Explicit Setup

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()
  // ...
}

Custom Formatting Style

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())

Installing on a Specific Handler

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)

Removing the Formatter

Restore OTP's default formatter:

import birch/erlang_logger

let assert Ok(Nil) = erlang_logger.remove_formatter()

Opting Out of :logger Integration

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 formatter

This is useful when:

  • You want birch output separate from OTP log output
  • You want to avoid changing the formatter on the default :logger handler
  • You are using another library that also configures :logger formatting

Using with Existing :logger Configurations

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 default handler'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.

Phoenix / Elixir Considerations

If you are using birch from an Elixir/Phoenix application:

  • Birch's ensure_formatter_configured() will override your :logger formatter config. Phoenix configures :logger formatting in config.exs. When birch's default config is first used, it replaces the formatter on the default handler.
  • 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.

Comparison with Other Logging Libraries

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 βœ… ❌ ❌ βœ…

Development

See DEV.md for development setup, testing, and contribution guidelines.

License

MIT License - see LICENSE for details.

About

Logs that gleam. ✨πŸͺ΅βœ¨ A comprehensive logging library in Gleam that targets both Erlang and JavaScript.

Topics

Resources

License

Stars

Watchers

Forks

Contributors