Skip to content
Last9
Book demo

Ruby on Rails

Send distributed traces to Last9 from a Ruby on Rails app using OpenTelemetry

Ruby on Rails is a server-side web application framework written in Ruby. This comprehensive guide will help you instrument your Ruby on Rails application with OpenTelemetry and smoothly send the traces to a Last9 cluster. You can also check out the example application on GitHub↗.

Pre-requisites

  1. You have a Ruby on Rails application.
  2. You have signed up for Last9, created a cluster, and obtained the following OTLP credentials from the Integrations page:
    • endpoint
    • auth_header

Install OpenTelemetry packages

To install the required packages, add the following lines to your Gemfile:

gem 'opentelemetry-sdk'
gem 'opentelemetry-exporter-otlp'
gem 'opentelemetry-instrumentation-all'
gem 'dotenv-rails', groups: [:development, :test]

Then, run the following command to install the packages:

bundle install

Set the environment variables

Create a .env file in the root directory of your application and add the following environment variables:

OTEL_SERVICE_NAME=ruby-on-rails-api-service
OTEL_EXPORTER_OTLP_ENDPOINT=<ENDPOINT>
OTEL_EXPORTER_OTLP_HEADERS="Authorization=Basic <BASIC_AUTH_HEADER>"
OTEL_TRACES_EXPORTER=otlp

Note: Replace <BASIC_AUTH_HEADER> with the URL encoded value of the basic auth header.

Instrument your application

In config/initializers/opentelemetry.rb, add the following code to instrument your application:

require 'opentelemetry/sdk'
require 'opentelemetry/exporter/otlp'
require 'opentelemetry/instrumentation/all'
# Exporter and Processor configuration
otel_exporter = OpenTelemetry::Exporter::OTLP::Exporter.new
processor = OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(otel_exporter)
OpenTelemetry::SDK.configure do |c|
# Exporter and Processor configuration
c.add_span_processor(processor) # Created above this SDK.configure block
# Resource configuration
c.resource = OpenTelemetry::SDK::Resources::Resource.create({
OpenTelemetry::SemanticConventions::Resource::SERVICE_NAME => 'ruby-on-rails-api-service',
OpenTelemetry::SemanticConventions::Resource::SERVICE_VERSION => "0.0.0",
OpenTelemetry::SemanticConventions::Resource::DEPLOYMENT_ENVIRONMENT => Rails.env.to_s
})
c.use_all() # enables all instrumentation!
end

This code snippet configures the OpenTelemetry SDK to use the OTLP exporter and enables instrumentation for all standard components of a Rails application.

Run the application

Start your Ruby on Rails application by running the following command:

bin/rails server

Visualize data

After running the Ruby on Rails app, you can visualize the traces in the Last9’s APM dashboard.

image


Elasticsearch Instrumentation

The elasticsearch Ruby gem (via elastic-transport >= 8.3) has native OpenTelemetry instrumentation — no extra gems needed. When it detects an OpenTelemetry SDK, it automatically creates spans for every Elasticsearch operation.

Add Elasticsearch gems

Add the following to your Gemfile:

gem 'elasticsearch', '~> 8.0'
gem 'elasticsearch-model', '~> 8.0'
gem 'elasticsearch-rails', '~> 8.0'

Then run:

bundle install

Configure the client

Create config/initializers/elasticsearch.rb:

Elasticsearch::Model.client = Elasticsearch::Client.new(
url: ENV.fetch("ELASTICSEARCH_URL", "http://localhost:9200"),
log: Rails.env.development?
)

What gets instrumented

The native instrumentation creates spans following OTel Semantic Conventions for Elasticsearch:

Span NameKey Attributes
search articlesdb.system=elasticsearch, db.operation.name=search
index articlesdb.system=elasticsearch, db.collection.name=articles
get articlesdb.system=elasticsearch, db.response.status_code
delete articlesdb.system=elasticsearch, http.request.method

Since opentelemetry-instrumentation-all includes Faraday instrumentation, you also get HTTP-level child spans showing the actual requests to the Elasticsearch cluster.

Add custom span attributes

You can enrich the auto-generated spans with business context in your controllers:

def search
current_span = OpenTelemetry::Trace.current_span
current_span.set_attribute("articles.query", params[:q])
response = Article.__elasticsearch__.client.search(
index: "articles",
body: { query: { match: { title: params[:q] } } }
)
current_span.set_attribute("articles.results_count", response.dig("hits", "total", "value"))
render json: response.dig("hits", "hits").map { |h| h["_source"] }
end

Capture search query body

By default, the request body (db.query.text) is not captured for privacy. To enable it:

export OTEL_RUBY_INSTRUMENTATION_ELASTICSEARCH_CAPTURE_SEARCH_QUERY=raw

Or use sanitize to redact sensitive fields:

export OTEL_RUBY_INSTRUMENTATION_ELASTICSEARCH_CAPTURE_SEARCH_QUERY=sanitize

Check out the full working example with Elasticsearch on GitHub↗.


Log-Trace Correlation

Correlating logs with traces helps you debug production issues by linking log entries to their corresponding distributed traces in Last9’s APM dashboard. This integration shows you how to add OpenTelemetry trace IDs and span IDs to your Rails application logs.

How It Works

OpenTelemetry provides the current trace context through OpenTelemetry::Trace.current_span.context. You can extract trace_id and span_id from this context and add them to your log entries. Last9 will automatically correlate these logs with traces when both share the same trace ID.

Implementation Examples

Choose the integration that matches your logging setup:

Rails Logger (Default)

Add trace context to Rails’ default logger:

# config/initializers/logging.rb
module LogTraceCorrelation
def add(severity, message = nil, progname = nil)
if block_given?
super(severity, progname) do
span = OpenTelemetry::Trace.current_span
context = span.context
"trace_id=#{context.hex_trace_id} span_id=#{context.hex_span_id} #{yield}"
end
else
span = OpenTelemetry::Trace.current_span
context = span.context
super(severity, "trace_id=#{context.hex_trace_id} span_id=#{context.hex_span_id} #{message}", progname)
end
end
end
Rails.logger.extend(LogTraceCorrelation)

Example output:

trace_id=4bf92f3577b34da6a3ce929d0e0e4736 span_id=00f067aa0ba902b7 User login successful

Lograge (Structured Logging)

If you’re using Lograge for structured logs:

# config/initializers/lograge.rb
Rails.application.configure do
config.lograge.enabled = true
config.lograge.custom_options = lambda do |event|
span = OpenTelemetry::Trace.current_span
context = span.context
{
trace_id: context.hex_trace_id,
span_id: context.hex_span_id,
host: event.payload[:host],
params: event.payload[:params].except('controller', 'action')
}
end
end

Example output:

{
"method": "GET",
"path": "/api/users/123",
"status": 200,
"duration": 145.23,
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7"
}

Semantic Logger

For Semantic Logger:

# config/initializers/semantic_logger.rb
Rails.application.configure do
config.semantic_logger.add_file_appender = true
config.rails_semantic_logger.add_file_appender = true
# Add trace context to all log entries
SemanticLogger.on_log do |log|
span = OpenTelemetry::Trace.current_span
context = span.context
log.named_tags[:trace_id] = context.hex_trace_id
log.named_tags[:span_id] = context.hex_span_id
end
end

Custom JSON Formatter

For applications using JSON-formatted logs:

# config/initializers/logging.rb
class JSONLogFormatter < Logger::Formatter
def call(severity, timestamp, progname, msg)
span = OpenTelemetry::Trace.current_span
context = span.context
log_entry = {
timestamp: timestamp.utc.iso8601,
level: severity,
message: msg,
trace_id: context.hex_trace_id,
span_id: context.hex_span_id
}
"#{log_entry.to_json}\n"
end
end
Rails.logger.formatter = JSONLogFormatter.new

Example output:

{
"timestamp": "2024-01-15T10:30:45Z",
"level": "INFO",
"message": "User login successful",
"trace_id": "4bf92f3577b34da6a3ce929d0e0e4736",
"span_id": "00f067aa0ba902b7"
}

Viewing Correlated Logs in Last9

Once configured, your logs will appear in Last9’s APM dashboard alongside their corresponding traces. You can:

  1. Click on any trace span to see related logs
  2. Click on any log entry to jump to its trace
  3. Filter logs by trace ID to see the complete request flow


Richer Rails Observability with rails-otel-context

The standard opentelemetry-instrumentation-all setup gives you spans for every DB query, but the spans lack caller context — you can see that a query ran, but not which service object or controller method triggered it.

rails-otel-context enriches every span — DB, Redis, HTTP outbound, custom — with attributes pointing to the application-code frame that originated the call:

AttributeExample valueWhere it comes from
code.namespaceOrderAnalyticsServiceNearest app-code class in the call stack
code.functionrevenue_summaryMethod within that class
code.filepathapp/services/order_analytics_service.rbApp-relative path
code.lineno42Source line number
rails.controllerReportsControllerCurrent Rails controller (every request span)
rails.actionindexCurrent Rails action
rails.jobMonthlyInvoiceJobActiveJob class (every job span)

DB spans additionally get:

AttributeExampleDescription
code.activerecord.modelTransactionActiveRecord model
code.activerecord.scoperecent_completedNamed scope or class method
db.query_count3Occurrence count this request — flags N+1 patterns
db.slowtrueSet when duration ≥ slow_query_threshold_ms

Install

Add to your Gemfile:

gem 'rails-otel-context', '~> 0.9'

Then run:

bundle install

Add the gem and boot Rails — everything is automatic. No include statements, no with_frame calls, no request_context_enabled flag needed.

Optional configuration

Create config/initializers/rails_otel_context.rb to enable span renaming and slow-query detection:

# frozen_string_literal: true
RailsOtelContext.configure do |config|
# Flag any query slower than 200ms with db.slow: true
config.slow_query_threshold_ms = 200
# Rename DB spans: scope > class method > AR operation
# Produces names like "Order.completed", "User.active", "Transaction.Load"
config.span_name_formatter = lambda { |original_name, ar_context|
model = ar_context[:model_name]
return original_name unless model
scope = ar_context[:scope_name]
code_fn = ar_context[:code_function]
code_ns = ar_context[:code_namespace]
ar_op = ar_context[:method_name]
method = if scope
scope
elsif code_fn && code_ns == model && !code_fn.start_with?('<')
code_fn
else
ar_op
end
"#{model}.#{method}"
}
end

What each DB span looks like

ReportsController#index calls BillingService#monthly_summary which queries Transaction:

span.name: "Transaction.recent_completed"
code.namespace: "BillingService" ← nearest app frame, not the controller
code.function: "monthly_summary"
code.filepath: "app/services/billing_service.rb"
code.lineno: 42
code.activerecord.model: "Transaction"
code.activerecord.scope: "recent_completed"
rails.controller: "ReportsController"
rails.action: "index"
db.query_count: 3
db.slow: true

code.namespace resolves to the nearest application frame in the call stack — so service objects, repositories, and jobs all show up correctly without any manual instrumentation.

Job spans

Every span created inside an ActiveJob carries rails.job instead of rails.controller:

span.name: "User.Load"
rails.job: "MonthlyInvoiceJob"
code.namespace: "MonthlyInvoiceJob"
code.function: "perform"

Override for hot paths

For code paths that generate thousands of spans per second, include Frameable to replace the per-span stack 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

For typical web requests (10–20 spans), the stack walk overhead is in the low-microsecond range — measure before optimizing.

Redis instrumentation

rails-otel-context enriches Redis spans produced by opentelemetry-instrumentation-redis. Every SET, GET, pipeline, and other Redis command carries the same code.* and rails.* attributes:

span.name: "SET"
code.namespace: "Api::V1::DemoController"
code.function: "redis_demo"
rails.controller: "Api::V1::DemoController"
rails.action: "redis_demo"

No extra configuration required — Redis enrichment activates automatically when the redis gem is present.

ClickHouse instrumentation

For applications using ClickHouse via the clickhouse-activerecord gem, rails-otel-context enriches ClickHouse spans with the full set of code.* attributes in the same way.

Example demo endpoint

The opentelemetry-examples Rails API includes a /api/v1/demo/redis endpoint that exercises all Redis adapter paths — SET, GET, pipeline, list operations — to confirm code.* attributes appear on every span.


Troubleshooting

Please get in touch with us on Discord or Email if you have any questions.