rails-otel-context
OpenTelemetry spans for Rails know a lot about your database. They know the SQL. They know how long it took. What they don't know is which code fired that query — the model, the scope, the controller, the line number. This gem fixes that.
Before and after
Without this gem, a database span looks like:
{
"name": "SELECT",
"db.system": "postgresql",
"db.statement": "SELECT * FROM transactions WHERE ...",
"duration_ms": 450
}
With this gem:
{
"name": "Transaction.recent_completed",
"db.system": "postgresql",
"db.statement": "SELECT * FROM transactions WHERE ...",
"duration_ms": 450,
"code.activerecord.model": "Transaction",
"code.activerecord.method": "Load",
"code.activerecord.scope": "recent_completed",
"code.namespace": "DashboardController",
"code.function": "index",
"code.filepath": "app/controllers/dashboard_controller.rb",
"code.lineno": 14,
"db.query_count": 47
}
You navigate straight to the offending line. No grepping, no guessing.
Installation
gem 'rails-otel-context', '~> 0.7'
That's it. Everything installs automatically when Rails boots.
Configuration
The defaults are sensible. A production initializer that gets the most out of this gem:
# config/initializers/rails_otel_context.rb
RailsOtelContext.configure do |c|
# Rename DB spans to Model.scope — makes traces scannable at a glance
c.span_name_formatter = ->(original, ar) {
model = ar[:model_name]
return original unless model
scope = ar[:scope_name] ||
(ar[:code_function] if ar[:code_namespace] == model && !ar[:code_function]&.start_with?('<')) ||
ar[:method_name]
"#{model}.#{scope}"
}
# Carry controller + action name into every DB span fired during the request
c.request_context_enabled = true
# Flag slow queries (sets db.slow: true on spans exceeding the threshold)
c.slow_query_threshold_ms = 500
# Attach any per-request context to every span in the trace
c.custom_span_attributes = -> { { 'team' => Current.team } if Current.team }
end
What gets added to your spans
| Attribute | Example | Description |
|---|---|---|
code.activerecord.model |
"Transaction" |
ActiveRecord model name |
code.activerecord.method |
"Load" |
AR operation (Load, Count, Update…) |
code.activerecord.scope |
"recent_completed" |
Named scope or class method that produced the query |
code.namespace |
"DashboardController" |
Ruby class that triggered the span |
code.function |
"index" |
Method name within that class |
code.filepath |
"app/controllers/..." |
App-relative source file |
code.lineno |
14 |
Line number |
request.controller |
"DashboardController" |
Rails controller (requires request_context_enabled) |
request.action |
"index" |
Rails action (requires request_context_enabled) |
db.query_count |
47 |
How many times this model+operation was queried in this request — appears only on the 2nd+ occurrence, which flags N+1 patterns |
db.slow |
true |
Set when query duration exceeds slow_query_threshold_ms |
db.async |
true |
Set when query was issued via load_async (Rails 7.1+) |
Span naming
Without a formatter, DB spans arrive named by the driver (SELECT, INSERT). The formatter in the example above renames them using what this gem knows about each query:
| What fired the query | Available context | Span name |
|---|---|---|
Transaction.recent_completed.to_a |
scope_name: "recent_completed" |
Transaction.recent_completed |
Transaction.total_revenue |
code_function: "total_revenue" |
Transaction.total_revenue |
Transaction.where(...).first |
method_name: "Load" |
Transaction.Load |
record.update(...) |
method_name: "Update" |
Transaction.Update |
Counter cache, connection.execute |
SQL parsed → table mapped to model | User.Update |
The original span name is preserved in l9.orig.name so you can always filter on the raw driver operation.
Counter caches and raw SQL
Rails counter caches, touch_later, and connection.execute calls fire sql.active_record with payload[:name] = "SQL" rather than "User Update". The gem parses the SQL statement directly and maps the table name back to the AR model:
UPDATE `users` SET `users`.`comments_count` = COALESCE(...)
→ code.activerecord.model: "User"
→ code.activerecord.method: "Update"
→ span renamed to "User.Update" (with formatter)
The table→model map is built from AR::Base.descendants on first use. In production with eager_load! this is always correct. In development, call this after a code reload if you see stale model names:
RailsOtelContext::ActiveRecordContext.reset_ar_table_model_map!
Scope tracking
The gem captures scope names from both the scope macro and plain class methods that return a relation:
class Transaction < ApplicationRecord
scope :recent_completed, -> { where(...) } # captured as code.activerecord.scope
def self.for_account(id) # also captured
where(account_id: id)
end
end
Redis
Redis source location tracking is off by default — it fires on every cache read and most Redis calls are fast. Turn it on when debugging:
c.redis_source_enabled = true
ClickHouse
No official OTel instrumentation exists for ClickHouse. This gem creates client spans automatically for ClickHouse::Client, ClickHouse::Connection, and Clickhouse::Client.
Benchmarks
The subscriber runs in the hot path of every SQL query. Two scripts live in bench/:
Allocation guard (also runs in CI)
Verifies the per-call allocation budget hasn't regressed. Deterministic — same count every run regardless of machine:
ruby bench/allocation_guard.rb
PASS named query (User Load): 4.0 allocs/call (budget: 4)
PASS SQL-named counter cache UPDATE: 6.0 allocs/call (budget: 6)
All allocation budgets met.
Exits 1 if any path exceeds its budget. Update the budget constant in bench/allocation_guard.rb whenever a deliberate trade-off is made.
Full profiling suite
Throughput, per-call allocations with top allocating lines, and a StackProf CPU flamegraph:
bundle exec ruby bench/subscriber_hot_path.rb
The flamegraph dump lands at tmp/subscriber.dump. View it with:
stackprof --flamegraph tmp/subscriber.dump | open -f -a 'Google Chrome'
Baseline (Ruby 3.3, Apple M-series):
| Path | Allocs/call | Throughput |
|---|---|---|
Named AR query (User Load) |
4 objects | ~900k i/s |
SQL counter cache (name=SQL) |
6 objects | ~650k i/s |
Requirements
- Ruby >= 3.1
- Rails >= 7.0
opentelemetry-api>= 1.0
License
MIT