Logsy
Tagged structured logging for Rails apps. JSON output, one line per log call.
Logsy gives you one wide JSON event per request plus correlated breadcrumb logs, all tagged with whatever per-request context you set via Logsy[:key] = value. Tags propagate across background jobs (Sidekiq middleware bundled). Where you ship the JSON is your call — Logsy just writes structured lines to stdout.
Installation
# Gemfile
gem 'logsy'
bundle install
Usage
1. Wire up the JSON formatter
# config/environments/production.rb
config.logger = ActiveSupport::Logger.new($stdout)
.tap { |l| l.formatter = Logsy::JsonFormatter.new }
2. Include the controller hooks
# app/controllers/application_controller.rb
class ApplicationController < ActionController::API
include Logsy::ControllerHooks
end
This gives you:
Logsy[:request_id]automatically populated fromrequest.request_id(orX-Request-Idheader) on every request- One "wide event" log line at end of each request with
event: "request", method, path, controller, action, status, duration_ms, and any error class/message — plus every tag you set during the request
3. Set tags wherever they become known
# In a controller, model, service, anywhere:
def create
order = Order.create!(order_params)
Logsy[:order_id] = order.id # every log from here on includes order_id
...
end
That's it. No Current model to define, no attribute declarations — just key/value.
4. (Optional) Background job propagation
If you use Sidekiq:
# config/initializers/sidekiq.rb
require 'logsy/sidekiq_middleware'
Sidekiq.configure_client do |config|
config.client_middleware { |chain| chain.add(Logsy::SidekiqMiddleware::Client) }
end
Sidekiq.configure_server do |config|
config.client_middleware { |chain| chain.add(Logsy::SidekiqMiddleware::Client) }
config.server_middleware { |chain| chain.add(Logsy::SidekiqMiddleware::Server) }
end
# config/initializers/logsy.rb (optional — only if you want to propagate more than request_id)
Logsy.configure do |c|
c.job_propagated_keys = %i[request_id user_id tenant_id]
end
The bundled middleware:
- Client side: when a job is enqueued, copies the configured tags from
Logsy[]into the job payload - Server side: when the job runs, sets those tags back in
Logsy[]AND setsLogsy[:job_id] = job['jid']automatically
Anything the job code writes via Logsy[:foo] = bar while running shows up on every log line emitted during the job. Tags reset automatically after each job (no leak between jobs sharing a worker thread).
For other job systems (Resque, GoodJob, custom), write your own middleware using the same pattern — Logsy's []= / [] / tags / reset API is generic.
What it looks like in your logs
A regular log line:
{
"ts":"2026-05-02T10:00:00.123Z",
"level":"INFO",
"msg":"Calling SPG gateway",
"file":"app/lib/gateways/spg_gateway.rb",
"line":437,
"request_id":"abc-123",
"user_id":"u-1",
"message_id":"m-42",
"order_id":"o-9"
}
The wide event at end of request:
{
"ts":"2026-05-02T10:00:00.250Z",
"level":"INFO",
"event":"request",
"method":"POST",
"path":"/v1/orders",
"controller":"orders",
"action":"create",
"status":201,
"duration_ms":127.34,
"request_id":"abc-123",
"user_id":"u-1",
"message_id":"m-42",
"order_id":"o-9",
}
Search your log store by message_id to find every request from that message. Pivot from a request's request_id to see every breadcrumb log line emitted while it ran.
API reference
Logsy[:user_id] = 'u-1' # set a tag
Logsy[:user_id] # read it
Logsy. # all tags as a hash
Logsy.reset # clear all tags (the controller/middleware do this for you)
Symbol and string keys are equivalent — Logsy['user_id'] and Logsy[:user_id] access the same slot.
Customizing the wide event
Override logsy_request_summary_extras in your controller to add fields:
class ApplicationController < ActionController::API
include Logsy::ControllerHooks
private
def logsy_request_summary_extras
{
ip: request.remote_ip,
user_agent: request.user_agent,
idempotency_key: request.headers['Idempotency-Key']
}
end
end
Configuration reference
Logsy.configure do |c|
# Tag keys to copy into job payloads at enqueue and read back at execution.
# Default: [:request_id]
c.job_propagated_keys = %i[request_id user_id tenant_id]
# Capture caller file:line via Kernel#caller_locations on every log line.
# Disable if you measure overhead. Default: true
c.include_caller_location = true
# Regex patterns for caller frames to skip. Defaults already cover Logger,
# ActiveSupport, lograge, sprockets, quiet_assets, and Logsy itself.
c.ignored_caller_paths += [%r{/my_internal_logger/}]
# The "event" name on the request summary. Default: "request"
c.request_summary_event_name = 'http_request'
end
Why Logsy?
Existing options each cover one piece:
tagged_logging(ActiveSupport) — push/pop tag stack, doesn't compose with mutable per-request contextlograge— one wide event per request, but doesn't help your in-actionRails.logger.info(...)callssemantic_logger— heavy, opinionated, replaces the whole logger framework
Logsy fills the gap: a small, dictionary-style API (Logsy[:foo] = bar) plus a JSON formatter that reads from it on every log line. No subclasses to define, no DSL to learn — just set tags as you discover them.
Development
bundle install
bundle exec rspec # 34 specs
bundle exec rubocop # lint
License
MIT. See LICENSE.txt.