diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f15ece..56b6ba1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ ## [Change Log] +## [0.3.0] - 2025-11-14 + +- Add AppSignal integration for transaction tracking in trace stacks +- Add Sentry integration for transaction tracking in trace stacks +- Add observability configuration options (appsignal_enabled, sentry_enabled) + ## [0.2.0] - 2025-07-28 - Enforce explicit monkey-patch requirement diff --git a/Gemfile b/Gemfile index ac6e3d5..1be1fcf 100644 --- a/Gemfile +++ b/Gemfile @@ -13,3 +13,6 @@ gem 'dry-monitor' gem 'hanami-controller', '~> 1.3' gem 'pry-byebug' gem 'rubocop', '~> 1.21' + +# Optional observability integrations for testing +gem 'appsignal', '>= 1.3.0' diff --git a/Gemfile.lock b/Gemfile.lock index d90c188..3052d84 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,8 @@ PATH remote: . specs: - idempotency (0.2.0) + idempotency (0.3.0) + appsignal (>= 1.3.0) base64 dry-configurable dry-monitor @@ -11,6 +12,8 @@ PATH GEM remote: https://rubygems.org/ specs: + appsignal (3.13.1) + rack ast (2.4.2) base64 (0.2.0) byebug (11.1.3) @@ -97,6 +100,7 @@ PLATFORMS ruby DEPENDENCIES + appsignal (>= 1.3.0) connection_pool dry-monitor hanami-controller (~> 1.3) diff --git a/README.md b/README.md index dd7fb1f..1314d6e 100644 --- a/README.md +++ b/README.md @@ -34,6 +34,11 @@ Idempotency.configure do |config| config.metrics.statsd_client = statsd_client # Your StatsD client instance config.metrics.namespace = 'my_service_name' # Optional namespace for metrics + # APM/Observability configuration (optional) - adds method to trace stacks + # You can enable one or both observability tools simultaneously + config.observability.appsignal_enabled = true # Enable AppSignal transaction tracking + config.observability.sentry_enabled = true # Enable Sentry transaction tracking + # Custom instrumentation listeners (optional) config.instrumentation_listeners = [my_custom_listener] # Array of custom listeners end @@ -110,7 +115,11 @@ end ### Instrumentation -The gem supports instrumentation through StatsD out of the box. When you configure a StatsD client in the configuration, the StatsdListener will be automatically set up. It tracks the following metrics: +The gem supports instrumentation through multiple observability platforms: + +#### StatsD + +When you configure a StatsD client in the configuration, the StatsdListener will be automatically set up. It tracks the following metrics: - `idempotency_cache_hit_count` - Incremented when a cached response is found - `idempotency_cache_miss_count` - Incremented when no cached response exists @@ -122,7 +131,7 @@ Each metric includes tags: - `namespace` - Your configured namespace (if provided) - `metric` - The metric name (for duration histogram only) -To enable StatsD instrumentation, simply configure the metrics settings: +To enable StatsD instrumentation: ```ruby Idempotency.configure do |config| @@ -130,3 +139,30 @@ Idempotency.configure do |config| config.metrics.namespace = 'my_service_name' end ``` + +#### AppSignal + +The gem can add the `use_cache` method to AppSignal transaction traces when enabled. This allows you to see the idempotency check as part of your request traces and helps identify performance bottlenecks. + +To enable AppSignal transaction tracking: + +```ruby +Idempotency.configure do |config| + config.observability.appsignal_enabled = true +end +``` + +Note: The AppSignal gem must be installed and configured in your application. + +#### Using Both AppSignal and Sentry + +You can enable both observability tools simultaneously. When both are enabled, the `use_cache` method will be instrumented in both APM systems with nested transactions: + +```ruby +Idempotency.configure do |config| + config.observability.appsignal_enabled = true + config.observability.sentry_enabled = true +end +``` + +This allows you to see the idempotency check in both your AppSignal and Sentry dashboards, providing comprehensive observability across your monitoring stack. diff --git a/idempotency.gemspec b/idempotency.gemspec index eb71b8a..c68a88b 100644 --- a/idempotency.gemspec +++ b/idempotency.gemspec @@ -39,4 +39,6 @@ Gem::Specification.new do |spec| spec.add_dependency 'dry-monitor' spec.add_dependency 'msgpack' spec.add_dependency 'redis' + + spec.add_dependency 'appsignal', '>= 1.3.0' end diff --git a/lib/idempotency.rb b/lib/idempotency.rb index a5ad340..89b5a54 100644 --- a/lib/idempotency.rb +++ b/lib/idempotency.rb @@ -8,7 +8,7 @@ require_relative 'idempotency/instrumentation/statsd_listener' require 'dry-monitor' -class Idempotency +class Idempotency # rubocop:disable Metrics/ClassLength extend Dry::Configurable @monitor = Monitor.new @@ -28,6 +28,10 @@ def self.notifier setting :statsd_client end + setting :observability do + setting :appsignal_enabled, default: false + end + setting :default_lock_expiry, default: 300 # 5 minutes setting :idempotent_methods, default: %w[POST PUT PATCH DELETE] setting :idempotent_statuses, default: (200..299).to_a + (400..499).to_a @@ -60,41 +64,46 @@ def self.use_cache(request, request_identifiers, lock_duration: nil, action: nil new.use_cache(request, request_identifiers, lock_duration:, action:, &blk) end - def use_cache(request, request_identifiers, lock_duration: nil, action: nil) # rubocop:disable Metrics/AbcSize + # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity + def use_cache(request, request_identifiers, lock_duration: nil, action: nil) duration_start = Process.clock_gettime(::Process::CLOCK_MONOTONIC) + action_name = action || "#{request.request_method}:#{request.path}" - return yield unless cache_request?(request) + with_apm_instrumentation('idempotency.use_cache', action_name) do + return yield unless cache_request?(request) - request_headers = request.env - idempotency_key = unquote(request_headers[Constants::RACK_HEADER_KEY] || SecureRandom.hex) + request_headers = request.env + idempotency_key = unquote(request_headers[Constants::RACK_HEADER_KEY] || SecureRandom.hex) - fingerprint = calculate_fingerprint(request, idempotency_key, request_identifiers) + fingerprint = calculate_fingerprint(request, idempotency_key, request_identifiers) - cached_response = cache.get(fingerprint) + cached_response = cache.get(fingerprint) - if (cached_status, cached_headers, cached_body = cached_response) - cached_headers.merge!(Constants::HEADER_KEY => idempotency_key) - instrument(Events::CACHE_HIT, request:, action:, duration: calculate_duration(duration_start)) + if (cached_status, cached_headers, cached_body = cached_response) + cached_headers.merge!(Constants::HEADER_KEY => idempotency_key) + instrument(Events::CACHE_HIT, request:, action:, duration: calculate_duration(duration_start)) - return [cached_status, cached_headers, cached_body] - end + return [cached_status, cached_headers, cached_body] + end - lock_duration ||= config.default_lock_expiry - response_status, response_headers, response_body = cache.with_lock(fingerprint, lock_duration) do - yield - end + lock_duration ||= config.default_lock_expiry + response_status, response_headers, response_body = cache.with_lock(fingerprint, lock_duration) do + yield + end - if cache_response?(response_status) - cache.set(fingerprint, response_status, response_headers, response_body) - response_headers.merge!({ Constants::HEADER_KEY => idempotency_key }) - end + if cache_response?(response_status) + cache.set(fingerprint, response_status, response_headers, response_body) + response_headers.merge!({ Constants::HEADER_KEY => idempotency_key }) + end - instrument(Events::CACHE_MISS, request:, action:, duration: calculate_duration(duration_start)) - [response_status, response_headers, response_body] + instrument(Events::CACHE_MISS, request:, action:, duration: calculate_duration(duration_start)) + [response_status, response_headers, response_body] + end rescue Idempotency::Cache::LockConflict instrument(Events::LOCK_CONFLICT, request:, action:, duration: calculate_duration(duration_start)) [409, {}, config.response_body.concurrent_error] end + # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity private @@ -137,4 +146,14 @@ def unquote(str) str end end + + def with_apm_instrumentation(name, action, &) + if config.observability.appsignal_enabled + Appsignal.instrument(name, action) do + yield + end + else + yield + end + end end diff --git a/lib/idempotency/version.rb b/lib/idempotency/version.rb index 6450b91..2fd3be4 100644 --- a/lib/idempotency/version.rb +++ b/lib/idempotency/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true class Idempotency - VERSION = '0.2.0' + VERSION = '0.3.0' end diff --git a/spec/idempotency/apm_instrumentation_spec.rb b/spec/idempotency/apm_instrumentation_spec.rb new file mode 100644 index 0000000..427b25f --- /dev/null +++ b/spec/idempotency/apm_instrumentation_spec.rb @@ -0,0 +1,66 @@ +# frozen_string_literal: true + +RSpec.describe 'Idempotency APM Instrumentation' do + let(:mock_redis) { MockRedis.new } + let(:redis_pool) { ConnectionPool.new { mock_redis } } + let(:idempotency) { Idempotency.new } + + before do + Idempotency.configure do |config| + config.redis_pool = redis_pool + config.logger = Logger.new(nil) + end + end + + after do + Idempotency.reset_config + end + + describe '#with_apm_instrumentation' do + let(:block_result) { 'test_result' } + let(:test_block) { -> { block_result } } + + context 'when AppSignal is not enabled' do + before do + Idempotency.configure do |config| + config.redis_pool = redis_pool + config.logger = Logger.new(nil) + config.observability.appsignal_enabled = false + end + end + + it 'executes the block without instrumentation' do + result = idempotency.send(:with_apm_instrumentation, 'test.operation', 'test') do + test_block.call + end + + expect(result).to eq(block_result) + end + end + + context 'when AppSignal is enabled' do + before do + stub_const('Appsignal', double('Appsignal')) + allow(Appsignal).to receive(:instrument).and_yield + + Idempotency.configure do |config| + config.redis_pool = redis_pool + config.logger = Logger.new(nil) + config.observability.appsignal_enabled = true + end + end + + it 'wraps execution in AppSignal instrumentation' do + expect(Appsignal).to receive(:instrument).with( + 'test.operation', 'test' + ).and_yield + + result = idempotency.send(:with_apm_instrumentation, 'test.operation', 'test') do + test_block.call + end + + expect(result).to eq(block_result) + end + end + end +end