Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

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

  1. Overview
  2. Installation
  3. Configuration
  4. Usage Patterns
  5. APMErrorHandler Wrapper
  6. Best Practices
  7. 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

  1. Gem Compatibility: Check for the latest elastic-apm gem version that supports Rails 8:

    # Gemfile
    gem 'elastic-apm', '~> 4.7'  # Check for Rails 8 compatible version
    
  2. 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
    
  3. Zeitwerk Compatibility: Ensure APMErrorHandler is autoloaded:

    # config/application.rb
    config.autoload_lib(ignore: %w[assets tasks])
    
  4. 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

PatternCode
Include in classinclude ElasticAPM::SpanHelpers
Add span to methodspan_method :method_name (after method)
Report errorAPMErrorHandler.report(error, context: {})
Report messageAPMErrorHandler.report('message', context: {})
Report with exceptionAPMErrorHandler.report('msg', error: e, context: {})
Manual spanElasticAPM.with_span('name') { block }
Custom labelElasticAPM.set_label(:key, value)
Custom contextElasticAPM.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

Additional Resources