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:
- Method arguments and return values
- Execution time and duration
- Exceptions with full stack traces
- OpenTelemetry trace context (W3C trace/span IDs)
- Fiber-aware span propagation for concurrent code
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
Manual Logging API
For libraries or situations where annotations aren't appropriate, Logit provides a manual logging API similar to Crystal's built-in Log:
# String-based logging
Logit.info("Processing started")
Logit.debug("User authenticated", user_id: 123)
Logit.warn("Slow query", duration_ms: 450, query: sql)
# Lazy evaluation - block only executed if logging is enabled
Logit.debug { "Expensive debug info: #{expensive_operation()}" }
# Exception logging
begin
risky_operation
rescue ex
Logit.exception("Operation failed", ex)
raise ex
end
Manual log calls automatically inherit trace context from any active span:
@[Logit::Log]
def process_order(order_id : Int64)
# This log call inherits trace_id and span_id from the annotation
Logit.info { "Starting order processing" }
validate_order(order_id)
Logit.info { "Order validation complete" }
end
Crystal Log Integration
Logit can capture all calls to Crystal's built-in Log library and route them through its backends, enabling unified export to OpenTelemetry collectors.
require "logit"
require "logit/integrations/crystal_log_adapter"
# Configure Logit first
Logit.configure do |config|
config.console(Logit::LogLevel::Debug)
config.otlp("http://localhost:4318/v1/logs")
end
# Install the adapter
Logit::Integrations::CrystalLogAdapter.install
# All Log.info/debug/etc calls now flow through Logit
Log.info { "This is captured by Logit and exported to OTLP" }
When the adapter is installed, Crystal Log calls automatically inherit Logit's trace context:
@[Logit::Log]
def process_request
# This Log call inherits the trace context from the span
Log.info { "Processing request" }
do_work
Log.info { "Request complete" }
end
Span Events
For long-running operations, you can add intermediate events to a span without creating separate spans:
@[Logit::Log]
def process_large_file(path : String) : Result
span = Logit::Span.current
span.add_event("file.opened", path: path)
data = read_file(path)
span.add_event("file.read", bytes: data.size)
result = process_data(data)
span.add_event("file.processed", records: result.size)
result
end
Span events appear in the JSON output:
{
"name": "process_large_file",
"events": [
{"name": "file.opened", "timestamp": "...", "attributes": {"path": "/data/file.csv"}},
{"name": "file.read", "timestamp": "...", "attributes": {"bytes": 1024}},
{"name": "file.processed", "timestamp": "...", "attributes": {"records": 42}}
]
}
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
- Exact match:
"MyLib::HTTP"matches onlyMyLib::HTTP - Single wildcard (
*): Matches a single component"MyLib::*"matchesMyLib::HTTPbut notMyLib::HTTP::Client"MyLib::HTTP::*"matchesMyLib::HTTP::Clientbut notMyLib::HTTP::Client::V2
- Multi wildcard (
**): Matches zero or more components"MyLib::**"matchesMyLib::HTTP,MyLib::HTTP::Client, etc."**"matches everything (root namespace)
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
- Most specific wins: When multiple patterns match, the longest (most specific) pattern takes precedence
- Unmatched namespaces: Use the backend's default level
- Per-backend: Bindings are scoped to each backend independently
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:
pattern: String - Glob pattern for namespace matchinglevel: LogLevel - Minimum log level for matching namespacesbackend: Backend - Backend to apply the binding to
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
Logit::Backend::Console- Outputs to STDOUT/STDERRLogit::Backend::File- Outputs to a fileLogit::Backend::OTLP- Exports to OpenTelemetry collectors via OTLP/HTTPLogit::Backend::Null- Discards all events (default backend)
Formatters
Logit::Formatter::Human- Human-readable text formatLogit::Formatter::JSON- JSON format (OpenTelemetry-compatible)
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})
Manual Logging Methods
Direct logging without annotations:
Logit.trace("message") # or Logit.trace { "lazy message" }
Logit.debug("message") # or Logit.debug { "lazy message" }
Logit.info("message") # or Logit.info { "lazy message" }
Logit.warn("message") # or Logit.warn { "lazy message" }
Logit.error("message") # or Logit.error { "lazy message" }
Logit.fatal("message") # or Logit.fatal { "lazy message" }
Logit.exception("msg", ex) # Log exception with stack trace
Library Integration
Logit is designed to be library-friendly. By default, it uses a NullBackend that discards all events, so libraries can use Logit without imposing logging on applications.
In Your Library
# my-lib/src/my-lib.cr
require "logit"
module MyLib
def self.query_database(sql : String) : Array(Result)
# Use manual logging - will be silent unless app configures Logit
Logit.debug { "Executing SQL: #{sql}" }
results = DB.query(sql)
Logit.info { "Query returned #{results.size} results" }
results
end
end
In Your Application
require "logit"
# Configure Logit to enable library logging
Logit.configure do |config|
config.console(Logit::LogLevel::Info)
# Enable debug logs for specific libraries
config.bind "MyLib::**", Logit::LogLevel::Debug, console
end
require "my-lib"
# Now library logs will appear
MyLib.query_database("SELECT * FROM users")
See docs/library_integration.md for detailed guidance.
Contributing
- Fork it
- Create your feature branch (
git checkout -b my-new-feature) - Commit your changes (
git commit -am 'Add some feature') - Push to the branch (
git push origin my-new-feature) - Create a new Pull Request
License
MIT License - see LICENSE for details.