DEV Community

Rodrigo Barreto
Rodrigo Barreto

Posted on

Trocando de APM sem dor de cabeça

O contexto

Semana passada um amigo me ligou perguntando qual ferramenta de APM/error tracking eu usava. Ele estava com o provedor atual espalhado por todo o projeto e queria trocar para algo diferente.

Eu uso AppSignal ou New Relic desde 2017 e gosto bastante. No caso dele aconselhei usar AppSignal por fatores que podemos falar em outro post para não ficar longo.

Mas a conversa foi além: "E se daqui a um ano eu quiser trocar de novo? Vou ter que fazer find & replace no projeto inteiro?"

Sugeri que ele criasse um adapter. Ele disse que nunca tinha implementado isso na prática.

Bom, implementamos juntos naquela tarde. E é exatamente isso que vou compartilhar aqui.


Antes: provedor acoplado em todo o código

O projeto dele estava assim:

# 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

O problema é claro: ProviderX espalhado em dezenas de arquivos. Para trocar de ferramenta, ele teria que:

  1. Buscar todas as ocorrências
  2. Entender o contexto de cada uma
  3. Adaptar para a nova API (que é diferente)
  4. Torcer para não ter esquecido nenhuma

Já passei por isso. Não é divertido.


Sobre over-engineering

Antes de mostrar a solução, preciso deixar algo claro: não sou fã de implementar design patterns para tudo. Gosto de manter as coisas simples.

Mas esse é um daqueles casos onde faz sentido:

  • É um serviço externo que pode mudar
  • A chamada está espalhada por dezenas de arquivos
  • A API de cada provedor é diferente
  • O custo de criar a abstração é baixo

Quando esses fatores se alinham, vale o investimento.


A solução: Adapter Pattern

A ideia é criar uma camada entre a aplicação e a ferramenta de tracking. A aplicação só conhece essa camada. A camada conhece a ferramenta específica.

Estrutura

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

1. Contrato base

Primeiro, definimos o contrato que todos os adapters devem seguir:

# 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. Adapter do AppSignal

A implementação que uso em produção:

# 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

Esse é um detalhe que faz diferença no dia a dia. Em desenvolvimento e testes, não queremos enviar erros para o provedor real. O NullAdapter resolve isso:

# 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)

O Tracker centraliza a API e adiciona alguns métodos utilitários que uso bastante:

# 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. Módulo principal

# 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. Configuração

Para quem for usar o AppSignal, adicione a gem:

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

# Error Tracking Configuration

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

No futuro, se quiser migrar para New Relic, Datadog, Bugsnag ou qualquer outro serviço, é só criar um novo adapter e trocar essa linha.


Depois: código desacoplado

# 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

O que ganhamos

Antes Depois
Trocar ferramenta = alterar dezenas de arquivos Trocar ferramenta = criar adapter + mudar 1 linha
Testes dependem do SDK Testes usam NullAdapter
Sem logs em development NullAdapter loga localmente
API diferente para cada ferramenta API única e consistente

Quando usar esse pattern?

Aplico quando:

  • É um serviço externo que pode mudar
  • A chamada está espalhada por vários arquivos
  • A API do serviço é complexa ou diferente entre provedores

Não aplico quando:

  • É usado em um único lugar
  • O serviço provavelmente nunca vai mudar
  • A complexidade não justifica

Conclusão

No final daquela tarde, o projeto do meu amigo estava pronto para o futuro. Se daqui a um ano ele quiser voltar pro provedor anterior, trocar pro Datadog, ou qualquer outra coisa, é só criar o adapter e mudar uma linha.

O código que compartilhei aqui é um rascunho do código real, porém os adapters funcionam tranquilamente em produção. Os controllers e exemplos de uso são fictícios para manter a integridade do projeto.

Top comments (1)

Collapse
 
rafaelsevla profile image
Rafael Costa

otimo