A Ruby gem for Rails and Rack. Accumulates request context, emits a
single structured event per request. Subscribes to existing
ActiveSupport::Notifications
— no monkey-patching, no boot-time wiring.
{ "timestamp": "2026-02-19T14:23:01.123Z", "duration_ms": 142.35, "request_id": "abc-123-def", "http_method": "POST", "path": "/orders", "http_status": 201, "controller": "OrdersController", "action": "create", "db_query_count": 8, "db_total_time_ms": 45.67, "view_runtime_ms": 12.35, "level": "info", "message": "POST /orders 201", "user": { "id": 7891, "email": "buyer@example.com" }, "business": { "order_id": 12345, "endpoint": "create_order" } }
One structured row per request. No joining, no correlating, no grepping across processes.
Grepping across processes, hoping request_id shows
up in every line.
Started GET "/api/users/123"
Processing by UsersController#show as JSON
User Load (2.1ms) SELECT *
Completed 200 OK in 45ms
(Views: 0.1ms | AR: 2.1ms)
Aggregate, slice, and pivot by any field. Drop straight into ClickHouse, BigQuery, or Datadog.
{ "duration_ms": 45.12,
"request_id": "abc-123-def",
"http_method": "GET",
"path": "/api/users/123",
"http_status": 200,
"db_query_count": 1 }
Works with Rails out of the box. For pure Rack apps, mount the middleware manually.
Then run bundle install.
gem "canonical_log"
Creates config/initializers/canonical_log.rb with
documented defaults.
bin/rails g canonical_log:install
No further setup — the Railtie wires the middleware, subscribers, and Warden detection automatically.
bin/rails s -e production
{ "timestamp": "2026-02-19T14:23:01.123Z", "duration_ms": 45.12, "request_id": "req_8bf7ec2d", "http_method": "GET", "path": "/api/users/42", "http_status": 200, "controller": "UsersController", "action": "show", "db_query_count": 1, "db_total_time_ms": 2.1, "view_runtime_ms": 0.12, "level": "info", "message": "GET /api/users/42 200", "user": { "id": 42, "email": "user@example.com" } }
Append fields, group them into categories, count, attach errors. Calls outside a request lifecycle silently no-op.
The simplest interface — append to the current event from a controller, service, or job.
Group related fields under :user,
:business, :infra, or
:service for a cleaner schema in your warehouse.
Attach the full exception with backtrace, plus your own metadata — without bouncing through three log lines.
CanonicalLog.add(order_id: 123, step: "payment") CanonicalLog.set(:checkout_step, "payment") CanonicalLog.increment(:external_api_calls) CanonicalLog.append(:feature_flags, "new_checkout")
CanonicalLog.context(:user, id: 42, tier: "premium") CanonicalLog.context(:business, endpoint: "create_order", order_id: 123) CanonicalLog.context(:service, version: "1.2.3", git_sha: "abc123")
begin process_payment rescue Stripe::CardError => e CanonicalLog.add_error(e, code: "card_declined", retriable: false) head :unprocessable_entity end
Thread-local event accumulates fields through the request. Rack middleware bookends it. Rails subscribers fill in the blanks. One JSON line emits at the end.
Middleware.init! creates the per-thread event.
request_idhttp_methodpathremote_ipuser_agent
controlleractionformatparamsview_runtime_msdb_runtime_ms
db_query_countdb_total_time_msslow_queries[]
user: { id, email }
CanonicalLog.context(:business, order_id: 123)CanonicalLog.add(checkout_step: "payment")
sample_rate.
Subscribes to existing
ActiveSupport::Notifications. No monkey-patching. No hooks to wire up.
opentelemetry-api is
loaded.
Every option has a documented default. Tune sampling, filters, and sinks per environment.
CanonicalLog.configure do |config| # Master on/off switch. Production-only by default. config.enabled = true # Silence Rails' default request log noise. config.suppress_rails_logging = Rails.env.production? # Filter sensitive params from output. config.param_filter_keys = %w[password token secret api_key] # Capture slow SQL individually (above threshold). config.slow_query_threshold_ms = 100.0 # Skip noisy endpoints entirely. config.ignored_paths = ["/health", "/assets", %r{\A/packs}] # Tail sampling — keep errors and slow requests automatically. config.sample_rate = 0.05 config.slow_request_threshold_ms = 1000.0 # Output format: :json | :pretty | :logfmt config.format = :json # Custom sink — send to Datadog, ClickHouse, BigQuery, … config.sinks = [ CanonicalLog::Sinks::Stdout.new, DatadogSink.new ] # Enrich every event just before emission. config.before_emit = ->(event) { event.set(:app_version, ENV["APP_VERSION"]) event.context(:infra, region: ENV["AWS_REGION"]) } end
Per-job canonical log lines with class, queue, jid, timing, and errors.
Sidekiq.configure_server do |c| c.server_middleware do |chain| chain.add CanonicalLog::Integrations::Sidekiq end end
trace_id and span_id auto-injected from
the current span — no setup required.
# Load opentelemetry-api. # Trace IDs appear automatically. gem "opentelemetry-api"
Anything responding to #write(json_string) is a
valid sink.
class DatadogSink < CanonicalLog::Sinks::Base def write(json) # forward to Datadog / Splunk / ... end end