gem canonical_log · v1.0.3 · MIT

One JSON log line
per request.

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.

gem install canonical_log
stdout · production
{
  "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" }
}
Why

Wide events vs. scattered logs

One structured row per request. No joining, no correlating, no grepping across processes.

Scattered · before

Five lines. No structure.

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)
Canonical · after

One row. Fully queryable.

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 }
Install

Get it running

Works with Rails out of the box. For pure Rack apps, mount the middleware manually.

1

Add to your Gemfile

Then run bundle install.

gem "canonical_log"
2

Run the generator

Creates config/initializers/canonical_log.rb with documented defaults.

bin/rails g canonical_log:install
3

Restart Rails

No further setup — the Railtie wires the middleware, subscribers, and Warden detection automatically.

bin/rails s -e production
first request · log/production.log
{
  "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" }
}
API

Add fields from anywhere

Append fields, group them into categories, count, attach errors. Calls outside a request lifecycle silently no-op.

Flat fields

The simplest interface — append to the current event from a controller, service, or job.

Categorized context

Group related fields under :user, :business, :infra, or :service for a cleaner schema in your warehouse.

Structured errors

Attach the full exception with backtrace, plus your own metadata — without bouncing through three log lines.

add fields
CanonicalLog.add(order_id: 123, step: "payment")
CanonicalLog.set(:checkout_step, "payment")
CanonicalLog.increment(:external_api_calls)
CanonicalLog.append(:feature_flags, "new_checkout")
categorized context
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")
structured errors
begin
  process_payment
rescue Stripe::CardError => e
  CanonicalLog.add_error(e,
    code: "card_declined", retriable: false)
  head :unprocessable_entity
end
Architecture

Request lifecycle

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.

  1. request POST /orders
    Middleware.init! creates the per-thread event.
  2. auto Rack middleware
    request_idhttp_methodpathremote_ipuser_agent
  3. auto ActionController subscriber
    controlleractionformatparamsview_runtime_msdb_runtime_ms
  4. auto ActiveRecord subscriber
    db_query_countdb_total_time_msslow_queries[]
  5. auto Warden / Devise
    user: { id, email }
  6. manual Your code
    CanonicalLog.context(:business, order_id: 123)
    CanonicalLog.add(checkout_step: "payment")
  7. sample Tail sampling
    Keeps errors (5xx). Keeps slow requests. Samples the rest at sample_rate.
  8. emit One JSON line → sinks
    stdout · logger · custom sink
Auto-captured fields

Filled in automatically

Subscribes to existing ActiveSupport::Notifications. No monkey-patching. No hooks to wire up.

Rack middleware
  • timestamp
  • duration_ms
  • request_id
  • http_method · path
  • remote_ip · user_agent
  • http_status
ActionController
  • controller
  • action
  • format
  • params (filtered)
  • view_runtime_ms
  • db_runtime_ms
ActiveRecord
  • db_query_count
  • db_total_time_ms
  • slow_queries[]
Cache
  • cache_read_count
  • cache_write_count
  • cache_hit_count
  • cache_miss_count
  • cache_total_time_ms
ActiveJob
  • job_class
  • queue
  • job_id
  • executions
  • duration_ms
OpenTelemetry
  • trace_id
  • span_id
  • Auto-injected when opentelemetry-api is loaded.
Configuration

Tunable knobs

Every option has a documented default. Tune sampling, filters, and sinks per environment.

config/initializers/canonical_log.rb
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
Integrations

Stack-aware adapters

Sidekiq

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

OpenTelemetry

trace_id and span_id auto-injected from the current span — no setup required.

# Load opentelemetry-api.
# Trace IDs appear automatically.
gem "opentelemetry-api"

Custom sinks

Anything responding to #write(json_string) is a valid sink.

class DatadogSink < CanonicalLog::Sinks::Base
  def write(json)
    # forward to Datadog / Splunk / ...
  end
end