Switching APM providers without the headache

Collapse
X
 
  • Time
  • Show
Clear All
new posts
  • MyrinNew
    Senior Member
    • Feb 2024
    • 5168

    #1

    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







    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







    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







    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(lev el))
    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(lev el))
    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[ath],
    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[arams]) if request_data[arams].present?
    Appsignal.add_headers(sanitize_headers(request_dat a[: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







    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








    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







    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







    6. Configuration

    For those using AppSignal, add the gem:






    # Gemfile
    gem "appsignal"











    # 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







    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










    What we gained

    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.




    More...
Working...