Logit

Annotation-based logging library for Crystal with OpenTelemetry support

Table of Contents

Background

Inspired by the principles outlined at loggingsucks.com, Logit provides a modern approach to logging in Crystal through annotation-based instrumentation. Instead of manually adding logging statements throughout your code, simply annotate methods with @[Logit::Log] and Logit automatically generates wrappers that capture:

The library follows OpenTelemetry semantic conventions, making it compatible with observability platforms that support OTLP or OpenTelemetry exporters.

Install

Add this to your application's shard.yml:

dependencies:
  logit:
    github: watzon/logit

Then run:

shards install

Require the library in your code:

require "logit"

Usage

Basic Setup

Configure Logit with a console backend:

require "logit"

Logit.configure do |config|
  config.console(Logit::LogLevel::Debug)
end

Annotation-Based Instrumentation

Simply annotate methods with @[Logit::Log] - no includes or setup calls required:

class Calculator
  @[Logit::Log]
  def add(x : Int32, y : Int32) : Int32
    x + y
  end

  @[Logit::Log]
  def divide(x : Int32, y : Int32) : Float64
    x / y
  end
end

calc = Calculator.new
calc.add(5, 3)
calc.divide(10, 2)

Output (Human formatter):

[INFO] 2025-01-05T21:30:00.123Z Calculator.add duration=2ms args={x: 5, y: 3} return=8
[INFO] 2025-01-05T21:30:00.125Z Calculator.divide duration=1ms args={x: 10, y: 2} return=5.0

Namespace Filtering

Logit supports namespace-based filtering, allowing libraries to use Logit internally while giving applications control over which logs they see. This is similar to Crystal's built-in Log library.

Logit.configure do |c|
  console = c.console(Logit::LogLevel::Info)

  # Log everything at Info level or above
  c.bind "*", LogLevel::Info, console

  # Enable Debug logging for HTTP library
  c.bind "MyLib::HTTP::*", LogLevel::Debug, console

  # Reduce noise from database library
  c.bind "MyLib::DB::*", LogLevel::Warn, console
end

Pattern Syntax

Multiple Backends

Different backends can have different namespace bindings:

Logit.configure do |c|
  console = c.console(Logit::LogLevel::Info)
  file = c.file("/var/log/app.log", LogLevel::Debug)

  # Console: only show warnings from database
  c.bind "MyLib::DB::*", LogLevel::Warn, console

  # File: log everything including debug from database
  c.bind "MyLib::DB::*", LogLevel::Debug, file
end

Matching Rules

Configuration Options

Multiple Backends

Logit.configure do |config|
  config.console(Logit::LogLevel::Debug)
  config.file("/var/log/app.log", LogLevel::Info)
end

OpenTelemetry Export

Send logs directly to an OpenTelemetry collector:

Logit.configure do |config|
  config.otlp(
    "http://localhost:4318/v1/logs",
    resource_attributes: {
      "service.name" => "my-app",
      "service.version" => "1.0.0"
    }
  )
end

Custom Formatters

require "logit/formatters/json"

Logit.configure do |config|
  backend = Logit::Backend::Console.new(
    name: "console",
    level: Logit::LogLevel::Info,
    formatter: Logit::Formatter::JSON.new
  )
  config.add_backend(backend)
end

Annotation Options

class UserService
  # Don't log arguments, use custom span name
  @[Logit::Log(log_args: false, name: "user.lookup")]
  def find_user(id : Int64) : User?
    # ...
  end

  # Don't log return value (useful for large responses)
  @[Logit::Log(log_return: false)]
  def fetch_all_users : Array(User)
    # ...
  end
end

OpenTelemetry Attributes

Logit supports OpenTelemetry semantic conventions. Set attributes on spans within instrumented methods:

class PaymentService
  @[Logit::Log]
  def process_payment(user_id : Int64, amount : Int64) : Bool
    # Access current span
    span = Logit::Span.current

    # Set OpenTelemetry attributes
    span.attributes.set("enduser.id", user_id)
    span.attributes.set("payment.amount", amount)
    span.attributes.set("payment.currency", "USD")

    # Your business logic here
    true
  end
end

JSON output includes all attributes:

{
  "trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
  "span_id": "00f067aa0ba902b7",
  "timestamp": "2025-01-05T21:30:00.123456Z",
  "duration_ms": 45,
  "name": "process_payment",
  "level": "info",
  "status": "ok",
  "code": {
    "file": "src/services/payment_service.cr",
    "line": 42,
    "function": "process_payment",
    "namespace": "PaymentService"
  },
  "attributes": {
    "enduser.id": "12345",
    "payment.amount": 1999,
    "payment.currency": "USD"
  }
}

API

Logit.configure

Configure the logging system with backends and tracers.

Logit.configure do |config|
  config.console(Logit::LogLevel::Debug)
  config.file("/path/to/log", LogLevel::Warn)
end

Logit.Config#bind

Bind a namespace pattern to a log level for a specific backend.

config.bind("MyLib::**", LogLevel::Debug, backend)

Parameters:

Logit::LogLevel

Enum of log levels: Trace, Debug, Info, Warn, Error, Fatal.

Logit::Span

Represents a traced operation with duration and attributes.

span = Logit::Span.new("operation.name")
span.attributes.set("key", "value")
span.end_time = Time.utc

Logit::Tracer

Routes events to backends. Access the default tracer:

Logit::Tracer.default.emit(event)

Backends

Formatters

Event::Attributes

Thread-safe storage for structured attributes.

attributes = Logit::Event::Attributes.new
attributes.set("string", "value")
attributes.set("number", 42)
attributes.set("bool", true)
attributes.set_object("nested", {key: "value", count: 1})

Contributing

  1. Fork it
  2. Create your feature branch (git checkout -b my-new-feature)
  3. Commit your changes (git commit -am 'Add some feature')
  4. Push to the branch (git push origin my-new-feature)
  5. Create a new Pull Request

License

MIT License - see LICENSE for details.