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
O problema é claro: ProviderX espalhado em dezenas de arquivos. Para trocar de ferramenta, ele teria que:
- Buscar todas as ocorrências
- Entender o contexto de cada uma
- Adaptar para a nova API (que é diferente)
- 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
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
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
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
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
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
6. Configuração
Para quem for usar o AppSignal, adicione a gem:
# Gemfile
gem "appsignal"
# 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
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
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)
otimo