OpenTelemetry Ruby allows you to instrument your Rails applications to automatically collect traces, metrics, and logs, giving you deep visibility into your application’s performance and behavior.
Let’s see it in action. Imagine a Rails app with a controller that calls an external HTTP service.
# app/controllers/posts_controller.rb
class PostsController < ApplicationController
def show
post = Post.find(params[:id])
response = Faraday.get("https://jsonplaceholder.typicode.com/posts/#{post.external_id}")
@external_data = JSON.parse(response.body)
render :show
end
end
With OpenTelemetry Ruby installed and configured, we can observe the following trace data:
{
"traceId": "...",
"spanId": "...",
"parentSpanId": "...",
"name": "GET /posts/:id",
"kind": "SPAN_KIND_SERVER",
"startTimeUnixNano": 1678886400000000000,
"endTimeUnixNano": 1678886401500000000,
"attributes": {
"http.method": "GET",
"http.route": "/posts/:id",
"http.status_code": 200,
"net.peer.ip": "127.0.0.1",
"net.peer.port": 3000,
"url.full": "http://localhost:3000/posts/1",
"user_agent.original": "..."
},
"events": [],
"status": {
"code": "STATUS_CODE_OK"
},
"traceState": "...",
"resource": {
"attributes": [
{"key": "service.name", "value": {"stringValue": "my-rails-app"}}
]
}
}
{
"traceId": "...",
"spanId": "...",
"parentSpanId": "...", // This links it to the GET /posts/:id span
"name": "GET https://jsonplaceholder.typicode.com/posts/1",
"kind": "SPAN_KIND_CLIENT",
"startTimeUnixNano": 1678886400800000000,
"endTimeUnixNano": 1678886401200000000,
"attributes": {
"http.method": "GET",
"http.url": "https://jsonplaceholder.typicode.com/posts/1",
"http.status_code": 200,
"net.peer.ip": "...",
"net.peer.port": 443,
"url.scheme": "https",
"url.host": "jsonplaceholder.typicode.com",
"url.path": "/posts/1"
},
"events": [],
"status": {
"code": "STATUS_CODE_OK"
},
"traceState": "...",
"resource": {
"attributes": [
{"key": "service.name", "value": {"stringValue": "my-rails-app"}}
]
}
}
The first span, GET /posts/:id, represents the incoming HTTP request to your Rails application. The second span, GET https://jsonplaceholder.typicode.com/posts/1, shows the outgoing HTTP request made by your application to an external service. Notice how parentSpanId links them, forming a trace. This automatically tells you how much time was spent within your application versus outside it.
To achieve this, you’d typically add the opentelemetry-gem to your Gemfile and configure it in an initializer:
# Gemfile
gem 'opentelemetry-gem'
gem 'opentelemetry-sdk'
gem 'opentelemetry-instrumentation-rails'
gem 'opentelemetry-instrumentation-faraday' # For the external HTTP call
# config/initializers/opentelemetry.rb
require 'opentelemetry/sdk'
require 'opentelemetry/exporter/otlp'
# Configure the SDK
OpenTelemetry::SDK.configure do |c|
c.service_name = 'my-rails-app'
c.use_all( # Enable all supported instrumentations
OpenTelemetry::Instrumentation::Rails,
OpenTelemetry::Instrumentation::Faraday
)
end
# Set up an exporter (e.g., OTLP to send to a collector)
# This requires a running OpenTelemetry Collector or compatible backend
# For local testing, you might use a console exporter:
# require 'opentelemetry/exporter/console'
# exporter = OpenTelemetry::Exporter::Console.new(deserializer: JSON)
exporter = OpenTelemetry::Exporter::OTLP.new(
# Default endpoint is http://localhost:4318
# Adjust if your collector is elsewhere
endpoint: ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4318')
)
# Set the tracer provider to use the exporter
tracer_provider = OpenTelemetry::SDK::Trace::TracerProvider.new(
span_processor: OpenTelemetry::SDK::Trace::Export::BatchSpanProcessor.new(exporter)
)
OpenTelemetry.tracer_provider = tracer_provider
# If you want to capture metrics and logs, you'd configure their respective providers and exporters here.
# For example, for metrics:
# require 'opentelemetry/metrics'
# require 'opentelemetry/metrics/exporter/otlp'
# meter_provider = OpenTelemetry::Metrics::MeterProvider.new(
# span_processor: OpenTelemetry::Metrics::Export::BatchMetricProcessor.new(
# OpenTelemetry::Metrics::Exporter::OTLP.new(endpoint: ENV.fetch('OTEL_EXPORTER_OTLP_ENDPOINT', 'http://localhost:4318'))
# )
# )
# OpenTelemetry.meter_provider = meter_provider
The core of OpenTelemetry instrumentation lies in semantic conventions. These are standardized attribute names and values that describe what happened. For example, http.method and http.status_code are standard attributes for HTTP requests. By adhering to these conventions, you make your telemetry data understandable across different tools and services. The OpenTelemetry::Instrumentation::Rails gem automatically applies these conventions to your Rails requests, database queries, and more. Similarly, OpenTelemetry::Instrumentation::Faraday does the same for outgoing HTTP requests made with Faraday.
The use_all method is a convenient way to enable multiple instrumentation packages. Each instrumentation package hooks into specific parts of your application’s execution flow (like a Rails controller action or a Faraday request) and creates spans based on predefined rules. These spans are then processed and exported.
The BatchSpanProcessor is crucial for performance. Instead of sending each span immediately, it buffers them and sends them in batches. This reduces the overhead on your application. The OTLP exporter is the standard way to send telemetry data to an OpenTelemetry Collector, which can then process, store, and route it to various backends like Jaeger, Prometheus, or logging systems.
A lesser-known but powerful feature is the ability to create custom spans. While automatic instrumentation is fantastic, sometimes you need to measure specific business logic that doesn’t map directly to an outgoing request or database query. You can do this using the tracer object:
tracer = OpenTelemetry.tracer_provider.tracer('my-custom-tracer', '1.0')
tracer.in_span("calculate_recommendations") do |span|
span.add_attributes({"user.id" => current_user.id})
# ... complex calculation logic ...
recommendations = perform_complex_calculation
span.set_attribute("recommendations.count", recommendations.size)
end
This creates a new span named calculate_recommendations that is a child of the current active span (e.g., the GET /posts/:id span). You can add custom attributes to these spans to provide more context, like the user.id or the number of recommendations. This allows you to pinpoint performance bottlenecks within your own code, not just external dependencies.
You can also configure sampling. By default, OpenTelemetry might sample every trace, which can be too noisy for high-traffic applications. You can configure a Sampler on the TracerProvider to control how many traces are recorded. For instance, a TraceIdRatioSampler with a ratio of 0.1 would sample 10% of all traces.
The next step after getting traces is to integrate metrics collection.