# Ruby on Rails

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

Source: https://last9.io/docs/integrations/frameworks/ruby/ruby-on-rails/

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↗](https://github.com/last9/opentelemetry-examples/tree/main/ruby/rails-api).

## Pre-requisites

1. You have a Ruby on Rails application.
2. You have signed up for [Last9](https://app.last9.io), created a cluster, and obtained the following OTLP credentials from the [Integrations](https://app.last9.io/integrations?integration=OpenTelemetry) page:
   - `endpoint`
   - `auth_header`

## Install OpenTelemetry packages

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

```ruby
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:

```bash
bundle install
```

## Set the environment variables

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

```bash
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:

```ruby
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:

```bash
bin/rails server
```

## Visualize data

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

![image](../../../../../../assets/content/docs/integrations/frameworks/ruby/ruby-on-rails/traces.png)

---

## 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:

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

Then run:

```bash
bundle install
```

### Configure the client

Create `config/initializers/elasticsearch.rb`:

```ruby
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](https://opentelemetry.io/docs/specs/semconv/db/elasticsearch/):

| Span Name         | Key Attributes                                           |
| ----------------- | -------------------------------------------------------- |
| `search articles` | `db.system=elasticsearch`, `db.operation.name=search`    |
| `index articles`  | `db.system=elasticsearch`, `db.collection.name=articles` |
| `get articles`    | `db.system=elasticsearch`, `db.response.status_code`     |
| `delete articles` | `db.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:

```ruby
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:

```bash
export OTEL_RUBY_INSTRUMENTATION_ELASTICSEARCH_CAPTURE_SEARCH_QUERY=raw
```

Or use `sanitize` to redact sensitive fields:

```bash
export OTEL_RUBY_INSTRUMENTATION_ELASTICSEARCH_CAPTURE_SEARCH_QUERY=sanitize
```

> Check out the full working example with Elasticsearch on [GitHub↗](https://github.com/last9/opentelemetry-examples/tree/main/ruby/rails-api).

---

## 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:

```ruby
# 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](https://github.com/roidrage/lograge) for structured logs:

```ruby
# 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:**

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

#### Semantic Logger

For [Semantic Logger](https://logger.rocketjob.io/):

```ruby
# 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:

```ruby
# 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:**

```json
{
  "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`](https://rubygems.org/gems/rails-otel-context) enriches every span — DB, Redis, HTTP outbound, custom — with attributes pointing to the application-code frame that originated the call:

| Attribute          | Example value                             | Where it comes from                           |
| ------------------ | ----------------------------------------- | --------------------------------------------- |
| `code.namespace`   | `OrderAnalyticsService`                   | Nearest app-code class in the call stack      |
| `code.function`    | `revenue_summary`                         | Method within that class                      |
| `code.filepath`    | `app/services/order_analytics_service.rb` | App-relative path                             |
| `code.lineno`      | `42`                                      | Source line number                            |
| `rails.controller` | `ReportsController`                       | Current Rails controller (every request span) |
| `rails.action`     | `index`                                   | Current Rails action                          |
| `rails.job`        | `MonthlyInvoiceJob`                       | ActiveJob class (every job span)              |

DB spans additionally get:

| Attribute                 | Example            | Description                                        |
| ------------------------- | ------------------ | -------------------------------------------------- |
| `code.activerecord.model` | `Transaction`      | ActiveRecord model                                 |
| `code.activerecord.scope` | `recent_completed` | Named scope or class method                        |
| `db.query_count`          | `3`                | Occurrence count this request — flags N+1 patterns |
| `db.slow`                 | `true`             | Set when duration ≥ `slow_query_threshold_ms`      |

### Install

Add to your `Gemfile`:

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

Then run:

```bash
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:

```ruby
# 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:

```ruby
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](https://clickhouse.com/) via the [`clickhouse-activerecord`](https://rubygems.org/gems/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](https://github.com/last9/opentelemetry-examples/tree/main/ruby/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.

---

## Running on Kubernetes

If you deploy your Rails app to Kubernetes, enrich traces with `k8s.*` semantic convention attributes (pod, namespace, node, deployment) using the Kubernetes Downward API. See [Kubernetes Resource Attributes](/docs/integrations/frameworks/ruby/kubernetes-resource-attributes/) for the deployment manifest.

## Troubleshooting

Please get in touch with us on [Discord](https://discord.com/invite/Q3p2EEucx9) or [Email](mailto:support@last9.io) if you have any questions.
