DEV Community

Rodrigo Barreto
Rodrigo Barreto

Posted on

Switching APM providers without the headache

The context

Last week a friend called me asking which APM/error tracking tool I use. He had his current provider scattered all over the project and wanted to switch to something different.

I've been using AppSignal or New Relic since 2019 and really like them. In his case, I recommended AppSignal for reasons we can discuss in another post to keep this one short.

But the conversation went further: "What if a year from now I want to switch again? Will I have to find & replace across the entire project?"

I suggested he create an adapter. He said he'd never implemented one in practice.

Well, we built it together that afternoon. And that's exactly what I'm going to share here.


Before: provider coupled throughout the code

His project looked like this:

# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def create
    @order = Order.new(order_params)

    if @order.save
      redirect_to @order
    else
      ProviderX.capture_message("Order creation failed", extra: { errors: @order.errors.full_messages })
      render :new
    end
  end
end

# app/services/payment_service.rb
class PaymentService
  def process(order)
    gateway.charge(order.total)
  rescue PaymentError => e
    ProviderX.capture_exception(e, extra: { order_id: order.id })
    raise
  end
end

# app/jobs/sync_inventory_job.rb
class SyncInventoryJob < ApplicationJob
  def perform
    InventorySync.run!
  rescue => e
    ProviderX.capture_exception(e, tags: { job: "inventory_sync" })
    raise
  end
end
Enter fullscreen mode Exit fullscreen mode

The problem is clear: ProviderX scattered across dozens of files. To switch tools, he would have to:

  1. Find all occurrences
  2. Understand the context of each one
  3. Adapt to the new API (which is different)
  4. Hope he didn't miss any

I've been there. It's not fun.


About over-engineering

Before showing the solution, I need to make something clear: I'm not a fan of implementing design patterns for everything. I like to keep things simple.

But this is one of those cases where it makes sense:

  • It's an external service that can change
  • The call is spread across dozens of files
  • Each provider's API is different
  • The cost of creating the abstraction is low

When these factors align, it's worth the investment.


The solution: Adapter Pattern

The idea is to create a layer between the application and the tracking tool. The application only knows this layer. The layer knows the specific tool.

Structure

app/
└── services/
    └── error_tracking/
        ├── base_adapter.rb
        ├── appsignal_adapter.rb
        ├── null_adapter.rb
        └── tracker.rb
Enter fullscreen mode Exit fullscreen mode

1. Base contract

First, we define the contract that all adapters must follow:

# app/services/error_tracking/base_adapter.rb
# frozen_string_literal: true

module ErrorTracking
  # Abstract base class for error tracking adapters
  # All adapters must implement these methods
  class BaseAdapter
    # Capture and report an exception
    # @param exception [Exception] the exception to capture
    # @param extra [Hash] additional context data
    # @param level [Symbol] severity level (:error, :warning, :info)
    # @param tags [Hash] tags for categorization
    def capture_exception(exception, extra: {}, level: :error, tags: {})
      raise NotImplementedError, "#{self.class} must implement #capture_exception"
    end

    # Capture and report a message
    # @param message [String] the message to capture
    # @param extra [Hash] additional context data
    # @param level [Symbol] severity level (:error, :warning, :info)
    # @param tags [Hash] tags for categorization
    def capture_message(message, extra: {}, level: :info, tags: {})
      raise NotImplementedError, "#{self.class} must implement #capture_message"
    end

    # Set user context for subsequent captures
    # @param user_data [Hash] user information (id, email, username, type, etc.)
    def set_user(user_data)
      raise NotImplementedError, "#{self.class} must implement #set_user"
    end

    # Set request context for subsequent captures
    # @param request_data [Hash] request information (method, path, url, ip, user_agent, params, headers)
    def set_request(request_data)
      raise NotImplementedError, "#{self.class} must implement #set_request"
    end

    # Set custom context data
    # @param context_data [Hash] custom context information
    def set_context(context_data)
      raise NotImplementedError, "#{self.class} must implement #set_context"
    end

    # Add custom context/scope
    # @yield [scope] block to configure scope
    def configure_scope(&)
      raise NotImplementedError, "#{self.class} must implement #configure_scope"
    end

    # Add breadcrumb for debugging trail
    # @param message [String] breadcrumb message
    # @param category [String] category for grouping
    # @param data [Hash] additional data
    def add_breadcrumb(message, category: "custom", data: {})
      raise NotImplementedError, "#{self.class} must implement #add_breadcrumb"
    end

    # Get trace propagation meta for distributed tracing (HTML views)
    # @return [String] HTML meta tags for tracing
    def get_trace_propagation_meta
      raise NotImplementedError, "#{self.class} must implement #get_trace_propagation_meta"
    end

    # Check if the adapter is enabled/configured
    # @return [Boolean]
    def enabled?
      raise NotImplementedError, "#{self.class} must implement #enabled?"
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

2. AppSignal Adapter

The implementation I use in production:

# app/services/error_tracking/appsignal_adapter.rb
# frozen_string_literal: true

module ErrorTracking
  class AppsignalAdapter < BaseAdapter
    def capture_exception(exception, extra: {}, level: :error, tags: {})
      return unless enabled?

      Appsignal.send_error(exception) do |transaction|
        transaction.set_namespace(namespace_from_level(level))
        transaction.set_tags(tags) if tags.any?
        transaction.set_params(extra) if extra.any?
      end
    end

    def capture_message(message, extra: {}, level: :info, tags: {})
      return unless enabled?

      Appsignal.send_error(StandardError.new(message)) do |transaction|
        transaction.set_namespace(namespace_from_level(level))
        transaction.set_tags(tags.merge(type: "message"))
        transaction.set_params(extra) if extra.any?
      end
    end

    def set_user(user_data)
      return unless enabled?

      Appsignal.add_tags(
        user_id: user_data[:id]&.to_s,
        user_email: user_data[:email],
        username: user_data[:username],
        user_type: user_data[:type]
      )
    end

    def set_request(request_data)
      return unless enabled?

      Appsignal.add_tags(
        request_method: request_data[:method],
        request_path: request_data[:path],
        request_url: truncate_value(request_data[:url]),
        request_ip: request_data[:ip],
        request_user_agent: truncate_value(request_data[:user_agent])
      )

      Appsignal.add_params(request_data[:params]) if request_data[:params].present?
      Appsignal.add_headers(sanitize_headers(request_data[:headers])) if request_data[:headers].present?
    end

    def set_context(context_data)
      return unless enabled?

      Appsignal.add_custom_data(context_data)
    end

    def configure_scope
      return unless enabled?

      yield(self) if block_given?
    end

    def add_breadcrumb(message, category: "custom", data: {})
      return unless enabled?

      Appsignal.add_breadcrumb(category, message, "", data)
    end

    def get_trace_propagation_meta
      ""
    end

    def enabled?
      defined?(Appsignal) && Appsignal.config&.active?
    end

    private

    def namespace_from_level(level)
      case level
      when :error then "error"
      when :warning then "warning"
      else "info"
      end
    end

    def truncate_value(value, max_length: 255)
      return nil if value.nil?

      value.to_s[0, max_length]
    end

    def sanitize_headers(headers)
      return {} if headers.nil?

      sensitive_keys = %w[authorization cookie set-cookie x-api-key api-key token]
      headers.transform_values.with_index do |(key, value), _|
        sensitive_keys.any? { |k| key.to_s.downcase.include?(k) } ? "[FILTERED]" : value
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

3. NullAdapter

This is a detail that makes a difference day to day. In development and tests, we don't want to send errors to the real provider. The NullAdapter solves this:

# frozen_string_literal: true

module ErrorTracking
  # Null Object pattern - does nothing, useful for development/test
  class NullAdapter < BaseAdapter
    def capture_exception(exception, extra: {}, level: :error, tags: {}) # rubocop:disable Lint/UnusedMethodArgument
      log_in_development("Exception: #{exception.class} - #{exception.message}", extra, level)
      nil
    end

    def capture_message(message, extra: {}, level: :info, tags: {}) # rubocop:disable Lint/UnusedMethodArgument
      log_in_development("Message: #{message}", extra, level)
      nil
    end

    def set_user(_user_data)
      nil
    end

    def set_request(_request_data)
      nil
    end

    def set_context(_context_data)
      nil
    end

    def configure_scope
      nil
    end

    def add_breadcrumb(_message, _category: "custom", _data: {})
      nil
    end

    def get_trace_propagation_meta
      ""
    end

    def enabled?
      false
    end

    private

    def log_in_development(message, extra, level)
      return unless Rails.env.development?

      Rails.logger.tagged("ErrorTracking") do
        Rails.logger.send(level == :error ? :error : :info, "#{message} | extra: #{extra.inspect}")
      end
    end
  end
end

Enter fullscreen mode Exit fullscreen mode

4. Tracker (facade)

The Tracker centralizes the API and adds some utility methods I use a lot:

# app/services/error_tracking/tracker.rb
# frozen_string_literal: true

module ErrorTracking
  # Main facade for error tracking
  # Usage:
  #   ErrorTracking.capture_exception(e, extra: { user_id: 123 })
  #   ErrorTracking.capture_message("Something happened", level: :warning)
  #
  # Configure in initializer:
  #   ErrorTracking.configure(adapter: ErrorTracking::AppsignalAdapter.new)
  class Tracker
    class << self
      attr_writer :adapter

      def adapter
        @adapter ||= NullAdapter.new
      end

      # Delegate all methods to the adapter
      delegate :capture_exception,
               :capture_message,
               :set_user,
               :set_request,
               :set_context,
               :configure_scope,
               :add_breadcrumb,
               :get_trace_propagation_meta,
               :enabled?,
               to: :adapter

      # Convenience method to capture exception with context and re-raise
      def capture_and_reraise(exception, extra: {}, level: :error, tags: {})
        capture_exception(exception, extra: extra, level: level, tags: tags)
        raise exception
      end

      # Safe capture that never raises (for use in rescue blocks)
      def safe_capture_exception(exception, extra: {}, level: :error, tags: {})
        capture_exception(exception, extra: extra, level: level, tags: tags)
      rescue StandardError => e
        Rails.logger.error("ErrorTracking failed to capture: #{e.message}")
        nil
      end

      # Safe message capture that never raises
      def safe_capture_message(message, extra: {}, level: :info, tags: {})
        capture_message(message, extra: extra, level: level, tags: tags)
      rescue StandardError => e
        Rails.logger.error("ErrorTracking failed to capture message: #{e.message}")
        nil
      end
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

5. Main module

# app/services/error_tracking.rb
# frozen_string_literal: true

module ErrorTracking
  class << self
    delegate :capture_exception,
             :capture_message,
             :set_user,
             :set_request,
             :set_context,
             :configure_scope,
             :add_breadcrumb,
             :get_trace_propagation_meta,
             :enabled?,
             :safe_capture_exception,
             :safe_capture_message,
             :capture_and_reraise,
             to: Tracker

    def configure(adapter:)
      Tracker.adapter = adapter
    end
  end
end
Enter fullscreen mode Exit fullscreen mode

6. Configuration

For those using AppSignal, add the gem:

# Gemfile
gem "appsignal"
Enter fullscreen mode Exit fullscreen mode
# config/initializers/error_tracking.rb
# frozen_string_literal: true

Rails.application.config.after_initialize do
  adapter = if Rails.env.in?(%w[development test])
              ErrorTracking::NullAdapter.new
            else
              ErrorTracking::AppsignalAdapter.new
            end

  ErrorTracking.configure(adapter: adapter)
end
Enter fullscreen mode Exit fullscreen mode

In the future, if you want to migrate to New Relic, Datadog, Bugsnag or any other service, just create a new adapter and change this line.


After: decoupled code

# app/controllers/orders_controller.rb
class OrdersController < ApplicationController
  def create
    @order = Order.new(order_params)

    if @order.save
      redirect_to @order
    else
      ErrorTracking.capture_message(
        "Order creation failed",
        extra: { errors: @order.errors.full_messages },
        level: :warning
      )
      render :new
    end
  end
end

# app/services/payment_service.rb
class PaymentService
  def process(order)
    ErrorTracking.add_breadcrumb("Starting payment", category: "payment")

    gateway.charge(order.total)
  rescue PaymentError => e
    ErrorTracking.capture_and_reraise(e, extra: { order_id: order.id })
  end
end

# app/controllers/application_controller.rb
class ApplicationController < ActionController::Base
  before_action :set_error_tracking_context

  private

  def set_error_tracking_context
    ErrorTracking.set_request(
      method: request.method,
      path: request.path,
      url: request.url,
      ip: request.remote_ip,
      user_agent: request.user_agent
    )

    return unless current_user

    ErrorTracking.set_user(
      id: current_user.id,
      email: current_user.email,
      username: current_user.name,
      type: current_user.class.name
    )
  end
end
Enter fullscreen mode Exit fullscreen mode

What we gained

Before After
Switching tools = changing dozens of files Switching tools = create adapter + change 1 line
Tests depend on SDK Tests use NullAdapter
No logs in development NullAdapter logs locally
Different API for each tool Single consistent API

When to use this pattern?

I apply it when:

  • It's an external service that can change
  • The call is spread across multiple files
  • The service API is complex or different between providers

I don't apply it when:

  • It's used in a single place
  • The service will probably never change
  • The complexity doesn't justify it

Conclusion

By the end of that afternoon, my friend's project was ready for the future. If a year from now he wants to go back to the previous provider, switch to Datadog, or anything else, he just needs to create the adapter and change one line.

The code I shared here is a draft of the real code, but the adapters work perfectly in production. The controllers and usage examples are fictional to maintain project integrity.

Top comments (0)