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:
- Find all occurrences
- Understand the context of each one
- Adapt to the new API (which is different)
- 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(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
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
| 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)