Annotation-based logging library for Crystal with OpenTelemetry support
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.
Add this to your application's shard.yml:
dependencies:
logit:
github: watzon/logitThen run:
shards installRequire the library in your code:
require "logit"Configure Logit with a console backend:
require "logit"
Logit.configure do |config|
config.console(Logit::LogLevel::Debug)
endSimply 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
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
endManual 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" }
endLogit 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" }
endFor 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
endSpan 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}}
]
}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- 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)
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- 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
Logit.configure do |config|
config.console(Logit::LogLevel::Debug)
config.file("/var/log/app.log", LogLevel::Info)
endSend 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"
}
)
endrequire "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)
endclass 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
endLogit 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
endJSON 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"
}
}Configure the logging system with backends and tracers.
Logit.configure do |config|
config.console(Logit::LogLevel::Debug)
config.file("/path/to/log", LogLevel::Warn)
endBind 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
Enum of log levels: Trace, Debug, Info, Warn, Error, Fatal.
Represents a traced operation with duration and attributes.
span = Logit::Span.new("operation.name")
span.attributes.set("key", "value")
span.end_time = Time.utcRoutes events to backends. Access the default tracer:
Logit::Tracer.default.emit(event)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)
Logit::Formatter::Human- Human-readable text formatLogit::Formatter::JSON- JSON format (OpenTelemetry-compatible)
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})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 traceLogit 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.
# 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
endrequire "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.
- 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
MIT License - see LICENSE for details.