ElasticAPM Implementation Guide for Rails
This guide documents how to implement Application Performance Monitoring (APM) using ElasticAPM in a Rails application. Based on the implementation patterns from the HungyHub project.
Table of Contents
- Overview
- Installation
- Configuration
- Usage Patterns
- APMErrorHandler Wrapper
- Best Practices
- Rails 8 Compatibility Notes
Overview
ElasticAPM provides:
- Distributed tracing - Track requests across services
- Performance monitoring - Identify slow methods and bottlenecks
- Error tracking - Capture and report exceptions with context
- Custom spans - Instrument specific methods for detailed performance data
Installation
1. Add the gem to your Gemfile
# Gemfile
gem 'elastic-apm'
2. Install the gem
bundle install
Configuration
Create config/elastic_apm.yml
# config/elastic_apm.yml
# Development configuration
development:
service_name: "your-app-dev"
server_url: "http://localhost:8200" # APM Server URL
environment: "development"
log_level: debug
log_path: "log/elastic_apm.log"
enabled: true
# Request settings
api_request_size: "1500kb"
api_request_time: "5s"
# Capture settings
capture_headers: true
capture_env: true
# Performance settings
pool_size: 10
transaction_sample_rate: 1.0
api_buffer_size: 256
# Production configuration
production:
service_name: "your-app-prod"
server_url: "<%= ENV['APM_SERVER_URL'] %>"
secret_token: "<%= ENV['APM_SECRET_TOKEN'] %>"
environment: "production"
log_level: warn
# Lower sample rate in production for high-traffic apps
transaction_sample_rate: 0.1
enabled: true
# Test configuration (usually disabled)
test:
enabled: false
Alternative: Environment Variables
# .env
ELASTIC_APM_SERVICE_NAME=your-app
ELASTIC_APM_SERVER_URL=http://apm-server:8200
ELASTIC_APM_ENVIRONMENT=production
ELASTIC_APM_SECRET_TOKEN=your-secret-token
Usage Patterns
BaseService already includes APMInstrumented, so all services have these helpers available. For non-service classes (e.g. query objects), include the concern explicitly:
class ModifierHistoryQuery
include APMInstrumented
end
1. Service Classes
Use apm_span to wrap a method in an APM span. The helper accepts an optional span type (default "app"). Always place it immediately after the method definition.
# app/services/pricing/price_calculator.rb
module Pricing
class PriceCalculator < BaseService
def call
# calculation logic
success(...)
rescue StandardError => e
log_error("Failed to calculate price", exception: e)
failure(...)
end
apm_span :call
private
def calculate_adjustment
# heavy math
end
apm_span :calculate_adjustment
end
end
2. Block-level spans
For ad-hoc instrumentation inside a method, use apm_with_span:
def call
apm_with_span("external_api_call", type: "external") do
RapidCanvas::Client.new.fetch_data
end
end
3. Labels and custom context
Add metadata to the current transaction:
def call
apm_set_label(:restaurant_id, restaurant_id)
apm_set_context(strategy: strategy, rule_count: matching_rules.size)
# ...
end
4. Concerns/Modules
For shared functionality in concerns:
# app/models/concerns/trackable.rb
module Trackable
extend ActiveSupport::Concern
included do
include APMInstrumented
apm_span :track_event
apm_span :calculate_metrics
end
def track_event(event_name)
# Tracking logic
end
def calculate_metrics
# Metrics calculation
end
end
3. Error Handling
Always report errors with context:
def perform_risky_operation
external_api.call
rescue Net::TimeoutError => e
APMErrorHandler.report('External API timeout',
error: e,
context: {
api_endpoint: external_api.endpoint,
timeout: external_api.timeout
}
)
fallback_value
rescue StandardError => e
APMErrorHandler.report('Unexpected error in risky operation',
error: e,
handled: false # Mark as unhandled for critical errors
)
raise # Re-raise if not handled
end
APMErrorHandler Wrapper
Create a wrapper class for consistent error reporting:
# app/lib/apm_error_handler.rb
class APMErrorHandler
class Reporter < StandardError
attr_reader :custom_backtrace
def initialize(message, custom_backtrace = nil)
super(message)
@custom_backtrace = custom_backtrace || caller
end
def backtrace
@custom_backtrace
end
end
# Report errors/messages to APM
#
# @param message_or_exception [String, Exception] The message or exception to report
# @param attrs [Hash] Additional attributes:
# - :context [Hash] Custom context data
# - :error/:exception/:e [Exception] Exception object
# - :handled [Boolean] Whether error was handled (default: true)
#
# @example Report a message
# APMErrorHandler.report('Something went wrong')
#
# @example Report an exception with context
# APMErrorHandler.report(exception, context: { user_id: 123 })
#
# @example Report a message with exception
# APMErrorHandler.report('API call failed', error: exception, context: { api: 'payments' })
#
def self.report(message_or_exception, **attrs)
# In development, raise errors for immediate visibility
if Rails.env.development? && !ElasticAPM.running?
raise message_or_exception if message_or_exception.is_a?(Exception)
raise Reporter.new(message_or_exception, Rails.backtrace_cleaner.clean(caller))
end
context = attrs.delete(:context) || {}
handled = attrs.fetch(:handled, true)
exception = attrs.delete(:exception) || attrs.delete(:error) || attrs.delete(:e)
# Merge remaining attrs into context
context.merge!(attrs)
context[:labels] ||= {}
# Build labels based on input type
if exception.is_a?(Exception)
context[:labels][:message] = message_or_exception if message_or_exception.is_a?(String)
context[:labels][:exception_message] = exception.message
elsif message_or_exception.is_a?(Exception)
context[:labels][:message] = message_or_exception.message
elsif message_or_exception.is_a?(String)
context[:labels][:message] = message_or_exception
end
set_context_and_labels(context)
report_to_apm(message_or_exception, exception, handled)
rescue StandardError => e
# Fallback to alternative error tracking (Rollbar, Sentry, etc.)
Rails.logger.error("APM reporting failed: #{e.message}")
end
# For use with the Retriable gem
def self.report_retriable_event(name)
Proc.new do |exception, try, elapsed_time, next_interval|
report("Retriable retrying #{name} attempt #{try}",
exception: exception,
context: {
try: try,
elapsed_time: elapsed_time,
next_interval: next_interval
}
)
end
end
private
def self.set_context_and_labels(context)
labels = context.delete(:labels) || {}
labels.each { |key, value| ElasticAPM.set_label(key, value) }
ElasticAPM.set_custom_context(context) if context.present?
end
def self.report_to_apm(message_or_exception, exception, handled)
if exception.is_a?(Exception)
ElasticAPM.report(exception, handled: handled)
elsif message_or_exception.is_a?(Exception)
ElasticAPM.report(message_or_exception, handled: handled)
elsif message_or_exception.is_a?(String)
error = Reporter.new(message_or_exception, caller)
ElasticAPM.report(error, handled: handled)
end
end
end
Best Practices
✅ DO
# 1. Always include SpanHelpers in classes with methods to track
class MyService
include ElasticAPM::SpanHelpers
def call
# ...
end
span_method :call # Immediately after method
end
# 2. Add span_method for expensive operations
def fetch_from_database
# Complex query
end
span_method :fetch_from_database
def call_external_api
# HTTP request
end
span_method :call_external_api
# 3. Report errors with meaningful context
APMErrorHandler.report('Payment processing failed',
error: exception,
context: {
user_id: user.id,
amount: payment.amount,
currency: payment.currency
}
)
# 4. Use span_method for methods in concerns
module Cacheable
extend ActiveSupport::Concern
included do
include ElasticAPM::SpanHelpers
span_method :fetch_from_cache
span_method :write_to_cache
end
end
❌ DON’T
# 1. Don't instrument trivial methods
def format_name(first, last)
"#{first} #{last}"
end
span_method :format_name # Unnecessary overhead
# 2. Don't forget context in error reports
APMErrorHandler.report(exception) # Missing context!
# 3. Don't place span_method before the method
span_method :my_method # Wrong!
def my_method
# ...
end
# 4. Don't instrument in tight loops
items.each do |item|
ElasticAPM.with_span('process_item') do # Too much overhead!
process(item)
end
end
Rails 8 Compatibility Notes
Changes for Rails 8
-
Gem Compatibility: Check for the latest
elastic-apmgem version that supports Rails 8:# Gemfile gem 'elastic-apm', '~> 4.7' # Check for Rails 8 compatible version -
Configuration Loading: Rails 8 may have different configuration loading. Ensure your initializer loads properly:
# config/initializers/elastic_apm.rb if defined?(ElasticAPM) && Rails.env.production? ElasticAPM.start end -
Zeitwerk Compatibility: Ensure APMErrorHandler is autoloaded:
# config/application.rb config.autoload_lib(ignore: %w[assets tasks]) -
Modern Ruby Features: The gem should work with Ruby 3.2+ patterns.
Local Development Stack
The local docker-compose.yml runs Elasticsearch, Kibana, and APM Server for end-to-end APM verification. The stack uses Elastic 7.17.x because it lets APM Server manage its own index templates directly; Elastic 8.x requires the Fleet APM integration which is heavier for local development.
# docker-compose.yml (APM services only)
services:
elasticsearch:
image: docker.elastic.co/elasticsearch/elasticsearch:7.17.18
environment:
- discovery.type=single-node
- xpack.security.enabled=false
- "ES_JAVA_OPTS=-Xms512m -Xmx512m"
ports:
- "9200:9200"
kibana:
image: docker.elastic.co/kibana/kibana:7.17.18
environment:
- ELASTICSEARCH_HOSTS=http://elasticsearch:9200
ports:
- "5601:5601"
depends_on:
- elasticsearch
apm-server:
image: docker.elastic.co/apm/apm-server:7.17.18
command: >
apm-server -e
-E output.elasticsearch.hosts=["http://elasticsearch:9200"]
-E apm-server.host=0.0.0.0:8200
-E apm-server.auth.secret_token=local-dev-token
-E setup.template.enabled=true
-E apm-server.kibana.enabled=true
-E apm-server.kibana.host=http://kibana:5601
ports:
- "8200:8200"
depends_on:
- elasticsearch
The Rails API and Solid Queue worker are configured with:
environment:
APM_ENABLED: "true"
APM_SERVER_URL: http://apm-server:8200
APM_SECRET_TOKEN: local-dev-token
APM_SERVICE_NAME: pricing-engine-development
APM_SAMPLE_RATE: "1.0"
APM_LOG_LEVEL: debug
Verify ingestion:
# 1. Start the stack
docker-compose up -d
# 2. Wait for APM Server health
curl http://localhost:8200
# 3. Generate traffic
curl -H "X-Internal-Api-Key: $INTERNAL_API_KEY" \
http://localhost:3001/api/v1/packages_pricing_rules/1631
# 4. Confirm traces in Elasticsearch
curl http://localhost:9200/_cat/indices/apm*
# 5. Open Kibana APM UI
open http://localhost:5601/app/apm/services/pricing-engine-development
Quick Reference
| Pattern | Code |
|---|---|
| Include in class | include ElasticAPM::SpanHelpers |
| Add span to method | span_method :method_name (after method) |
| Report error | APMErrorHandler.report(error, context: {}) |
| Report message | APMErrorHandler.report('message', context: {}) |
| Report with exception | APMErrorHandler.report('msg', error: e, context: {}) |
| Manual span | ElasticAPM.with_span('name') { block } |
| Custom label | ElasticAPM.set_label(:key, value) |
| Custom context | ElasticAPM.set_custom_context(hash) |
Example: Complete Service Implementation
# app/services/payment_service.rb
class PaymentService
include ElasticAPM::SpanHelpers
def initialize(user:, amount:, currency:)
@user = user
@amount = amount
@currency = currency
end
def process
validate_payment
charge_result = charge_payment
record_transaction(charge_result)
{ success: true, transaction_id: charge_result.id }
rescue PaymentGateway::Error => e
handle_payment_error(e)
rescue StandardError => e
handle_unexpected_error(e)
end
span_method :process
private
def validate_payment
raise ArgumentError, 'Invalid amount' if @amount <= 0
end
span_method :validate_payment
def charge_payment
PaymentGateway.charge(
user: @user,
amount: @amount,
currency: @currency
)
end
span_method :charge_payment
def record_transaction(result)
Transaction.create!(
user: @user,
amount: @amount,
external_id: result.id
)
end
span_method :record_transaction
def handle_payment_error(error)
APMErrorHandler.report('Payment gateway error',
error: error,
context: {
user_id: @user.id,
amount: @amount,
currency: @currency,
gateway_code: error.code
}
)
{ success: false, error: error.message }
end
def handle_unexpected_error(error)
APMErrorHandler.report('Unexpected payment error',
error: error,
handled: false,
context: {
user_id: @user.id,
amount: @amount
}
)
raise
end
end