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 service object, the scope, the job, 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": "BillingService",
"code.function": "monthly_summary",
"code.filepath": "app/services/billing_service.rb",
"code.lineno": 42,
"rails.controller": "ReportsController",
"rails.action": "index",
"db.query_count": 3
}
Notice code.namespace is BillingService, not ReportsController — the gem walks the call stack and finds the service object that actually issued the query, not the controller that dispatched the request. No configuration required.
Installation
gem 'rails-otel-context', '~> 0.9'
Add the gem, boot Rails. Everything else happens automatically.
What gets added to your spans
Every span — DB, Redis, HTTP outbound, custom — gets:
| Attribute | Example | Where it comes from |
|---|---|---|
code.namespace |
"BillingService" |
Nearest app-code class in the call stack |
code.function |
"monthly_summary" |
Method within that class |
code.filepath |
"app/services/billing_service.rb" |
App-relative path |
code.lineno |
42 |
Source line number |
rails.controller |
"ReportsController" |
Current Rails controller (set for every request) |
rails.action |
"index" |
Current Rails action |
rails.job |
"MonthlyInvoiceJob" |
ActiveJob class (set for every job, mutually exclusive with rails.controller) |
DB spans additionally get:
| Attribute | Example | Description |
|---|---|---|
code.activerecord.model |
"Transaction" |
ActiveRecord model |
code.activerecord.method |
"Load" |
AR operation (Load, Count, Update…) |
code.activerecord.scope |
"recent_completed" |
Named scope or class method |
db.query_count |
3 |
Occurrence count this request — 2nd+ flags N+1 patterns |
db.slow |
true |
Set when duration ≥ slow_query_threshold_ms |
db.async |
true |
Set when issued via load_async (Rails 7.1+) |
Configuration
Zero configuration gets you everything above. The optional initializer adds span naming and slow-query detection:
# config/initializers/rails_otel_context.rb
RailsOtelContext.configure do |c|
# Rename DB spans: prefer scope name, then calling method, then AR operation
#
# Priority (highest to lowest):
# 1. scope_name — named scope or class method returning a Relation
# e.g. User.active, Transaction.recent_completed
# 2. code_function when code_namespace == model — the model's own class method
# e.g. Transaction.total_revenue, User.for_account
# 3. method_name — AR operation: Load, Count, Update, Destroy…
# e.g. Transaction.Load, Transaction.Update
c.span_name_formatter = lambda { |original, ar|
model = ar[:model_name]
return original unless model
scope = ar[:scope_name]
code_fn = ar[:code_function]
code_ns = ar[:code_namespace]
ar_op = ar[:method_name]
method = if scope
scope
elsif code_fn && code_ns == model && !code_fn.start_with?('<')
code_fn
else
ar_op
end
"#{model}.#{method}"
}
# Flag slow queries
c.slow_query_threshold_ms = 500
# Attach any per-request context to every span
c.custom_span_attributes = -> { { 'tenant' => Current.tenant } if Current.tenant }
end
Conditional loading (require: false)
If your Gemfile has require: false and you load the gem from an initializer, call RailsOtelContext.install! explicitly. Loading the gem inside config/initializers/ is too late for Rails to run the railtie's initializer hooks, so without an explicit install! call the AR subscriber and around_action hooks are never registered — code.activerecord.* and rails.controller will be absent from all spans.
# Gemfile
gem 'rails-otel-context', '~> 0.9', require: false
# config/initializers/opentelemetry.rb
return unless ENV['ENABLE_OTLP']
require 'rails_otel_context'
RailsOtelContext.configure do |c|
c.span_name_formatter = lambda { |original, ar| ... }
end
RailsOtelContext.install! # registers AR hooks, around_action, and the span processor
require 'opentelemetry/sdk'
require 'opentelemetry/exporter/otlp'
require 'opentelemetry/instrumentation/all'
OpenTelemetry::SDK.configure do |c|
c.service_name = ENV.fetch('OTEL_SERVICE_NAME', 'my_app')
c.use_all
end
install! is idempotent — the railtie calls it automatically via after_initialize, so apps that let Bundler auto-require the gem do not need to call it.
How code.namespace / code.function works
On every span start, the gem walks the Ruby call stack (Thread.each_caller_location) and finds the first frame inside Rails.root. That frame becomes the four code.* attributes.
This means the right class shows up automatically at every layer:
| Caller | code.namespace |
code.function |
|---|---|---|
ReportsController#index calls BillingService#monthly_summary which queries |
BillingService |
monthly_summary |
UserRepository#find_active queries directly |
UserRepository |
find_active |
OrdersController#create queries directly |
OrdersController |
create |
MonthlyInvoiceJob#perform queries |
MonthlyInvoiceJob |
perform |
No include statements. No with_frame calls. The nearest frame wins.
Override for hot paths
The stack walk is O(stack depth) — roughly 15–25 frame iterations before reaching app code. For code paths that create thousands of spans per second, FrameContext.with_frame replaces the walk with a single thread-local read:
class ReportingPipeline
include RailsOtelContext::Frameable
def run
# All spans inside this block skip the stack walk.
# code.namespace: "ReportingPipeline", code.function: "run"
with_otel_frame { process_all_accounts }
end
end
The pushed frame takes priority for the duration of the block. Outside the block, automatic stack-walk resumes.
Span naming
Without a formatter, DB spans carry the driver's name (SELECT, INSERT). With the example formatter above:
| Query | Result |
|---|---|
Transaction.recent_completed.to_a |
Transaction.recent_completed |
Transaction.total_revenue (class method) |
Transaction.total_revenue |
Transaction.where(...).first |
Transaction.Load |
record.update(...) |
Transaction.Update |
Counter cache / connection.execute |
User.Update (SQL parsed → table → model) |
The original name is preserved in l9.orig.name.
Counter caches and raw SQL
Rails counter caches, touch_later, and connection.execute fire sql.active_record with payload[:name] = "SQL". The gem parses the statement and maps the table back to an AR model:
UPDATE `users` SET `users`.`comments_count` = ...
→ code.activerecord.model: "User", method: "Update"
→ span renamed to "User.Update"
Scope tracking
Both scope macro methods and plain class methods returning a Relation are captured:
class Transaction < ApplicationRecord
scope :recent_completed, -> { where(...) } # code.activerecord.scope: "recent_completed"
def self.for_account(id) # also captured
where(account_id: id)
end
end
Redis and ClickHouse
Redis and ClickHouse spans get the same code.* attributes pointing to the app-code frame that issued the call:
{
"name": "SET",
"db.system": "redis",
"code.namespace": "SessionStore",
"code.function": "write",
"code.filepath": "app/lib/session_store.rb",
"code.lineno": 18,
"rails.controller": "SessionsController",
"rails.action": "create"
}
Performance
CallContextProcessor#on_start fires for every span. For a typical 10–20 span request the overhead is in the low-microsecond range and does not require configuration.
For code paths that generate hundreds of spans per second, FrameContext.with_frame / Frameable#with_otel_frame replace the per-span stack walk with a single thread-local read. See Override for hot paths.
Boot cost
ScopeNameTracking hooks singleton_method_added on every AR model to detect class methods returning a Relation. On a large app (100+ models), this fires thousands of times during warm-up — one source_location call and one method redefinition per class method in app/. This is a one-time startup cost, not a per-request cost.
ar_table_model_map is built once at boot from AR::Base.descendants. In development, call RailsOtelContext::ActiveRecordContext.reset_ar_table_model_map! after a code reload if model names look stale.
Requirements
- Ruby >= 3.1
- Rails >= 7.0
opentelemetry-api>= 1.0
License
MIT