From fe996d487936408ab72b13b1eb096a600c1ab1c4 Mon Sep 17 00:00:00 2001 From: HNKhoi2410 Date: Fri, 14 Nov 2025 11:50:51 +0700 Subject: [PATCH 1/4] Add AppSignal and Sentry APM instrumentation (v0.3.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add transaction tracking for AppSignal (>= 2.0, < 4.0) - Add transaction tracking for Sentry (>= 4.1.0) - Support simultaneous instrumentation in both APM tools - Add observability configuration (appsignal_enabled, sentry_enabled) - Add comprehensive unit tests for APM instrumentation - Update documentation with usage examples - Bump version to 0.3.0 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- CHANGELOG.md | 6 + Gemfile | 4 + Gemfile.lock | 10 +- README.md | 54 +++- lib/idempotency.rb | 91 ++++-- lib/idempotency/version.rb | 2 +- spec/idempotency/apm_instrumentation_spec.rb | 284 +++++++++++++++++++ 7 files changed, 426 insertions(+), 25 deletions(-) create mode 100644 spec/idempotency/apm_instrumentation_spec.rb 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..1897aa2 100644 --- a/Gemfile +++ b/Gemfile @@ -13,3 +13,7 @@ gem 'dry-monitor' gem 'hanami-controller', '~> 1.3' gem 'pry-byebug' gem 'rubocop', '~> 1.21' + +# Optional observability integrations for testing +gem 'appsignal', '>= 2.0', '< 4.0' +gem 'sentry-ruby', '>= 4.1.0' diff --git a/Gemfile.lock b/Gemfile.lock index d90c188..d56d88e 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - idempotency (0.2.0) + idempotency (0.3.0) base64 dry-configurable dry-monitor @@ -11,8 +11,11 @@ PATH GEM remote: https://rubygems.org/ specs: + appsignal (3.13.1) + rack ast (2.4.2) base64 (0.2.0) + bigdecimal (3.3.1) byebug (11.1.3) coderay (1.1.3) concurrent-ruby (1.3.4) @@ -88,6 +91,9 @@ GEM rubocop-ast (1.35.0) parser (>= 3.3.1.0) ruby-progressbar (1.13.0) + sentry-ruby (6.1.0) + bigdecimal + concurrent-ruby (~> 1.0, >= 1.0.2) transproc (1.1.1) unicode-display_width (2.6.0) zeitwerk (2.6.18) @@ -97,6 +103,7 @@ PLATFORMS ruby DEPENDENCIES + appsignal (>= 2.0, < 4.0) connection_pool dry-monitor hanami-controller (~> 1.3) @@ -105,6 +112,7 @@ DEPENDENCIES pry-byebug rspec (~> 3.0) rubocop (~> 1.21) + sentry-ruby (>= 4.1.0) BUNDLED WITH 2.5.11 diff --git a/README.md b/README.md index dd7fb1f..e2765a0 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,44 @@ 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. + +#### Sentry + +The gem can add the `use_cache` method to Sentry performance traces when enabled. This allows you to see the idempotency check as part of your request traces and automatically captures any errors that occur. + +To enable Sentry transaction tracking: + +```ruby +Idempotency.configure do |config| + config.observability.sentry_enabled = true +end +``` + +Note: The Sentry 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/lib/idempotency.rb b/lib/idempotency.rb index a5ad340..76c6ec8 100644 --- a/lib/idempotency.rb +++ b/lib/idempotency.rb @@ -8,6 +8,7 @@ require_relative 'idempotency/instrumentation/statsd_listener' require 'dry-monitor' +# rubocop:disable Metrics/ClassLength class Idempotency extend Dry::Configurable @monitor = Monitor.new @@ -28,6 +29,11 @@ def self.notifier setting :statsd_client end + setting :observability do + setting :appsignal_enabled, default: false + setting :sentry_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 +66,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: 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 +148,42 @@ def unquote(str) str end end + + # rubocop:disable Metrics/AbcSize + def with_apm_instrumentation(name, tags = {}, &block) + # Build nested instrumentation layers from innermost to outermost + instrumented_block = block + + # Wrap with Sentry if enabled + if config.observability.sentry_enabled && defined?(Sentry) + instrumented_block = lambda do + transaction = Sentry.start_transaction(name: name, op: 'idempotency', **tags) + Sentry.get_current_scope.set_span(transaction) + + begin + block.call + rescue StandardError => e + Sentry.capture_exception(e) + raise + ensure + transaction.finish + end + end + end + + # Wrap with AppSignal if enabled (outermost layer) + if config.observability.appsignal_enabled && defined?(Appsignal) + Appsignal.monitor_transaction(name, tags) do + instrumented_block.call + rescue StandardError => e + Appsignal.set_error(e) + raise + end + else + # Execute the (potentially Sentry-wrapped) block + instrumented_block.call + end + end + # rubocop:enable Metrics/AbcSize end +# rubocop:enable Metrics/ClassLength 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..e95cc43 --- /dev/null +++ b/spec/idempotency/apm_instrumentation_spec.rb @@ -0,0 +1,284 @@ +# 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 neither AppSignal nor Sentry is enabled' do + before do + Idempotency.configure do |config| + config.redis_pool = redis_pool + config.logger = Logger.new(nil) + config.observability.appsignal_enabled = false + config.observability.sentry_enabled = false + end + end + + it 'executes the block without instrumentation' do + result = idempotency.send(:with_apm_instrumentation, 'test.operation', action: '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')) + + Idempotency.configure do |config| + config.redis_pool = redis_pool + config.logger = Logger.new(nil) + config.observability.appsignal_enabled = true + config.observability.sentry_enabled = false + end + end + + it 'wraps execution in AppSignal transaction' do + expect(Appsignal).to receive(:monitor_transaction).with( + 'test.operation', + { action: 'test' } + ).and_yield + + result = idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do + test_block.call + end + + expect(result).to eq(block_result) + end + + it 'reports errors to AppSignal and re-raises' do + test_error = StandardError.new('test error') + + expect(Appsignal).to receive(:monitor_transaction).with( + 'test.operation', + { action: 'test' } + ).and_yield + + expect(Appsignal).to receive(:set_error).with(test_error) + + expect do + idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do + raise test_error + end + end.to raise_error(StandardError, 'test error') + end + + it 'handles exceptions during error reporting gracefully' do + test_error = StandardError.new('test error') + + expect(Appsignal).to receive(:monitor_transaction).with( + 'test.operation', + { action: 'test' } + ).and_yield + + expect(Appsignal).to receive(:set_error).with(test_error).and_raise('AppSignal error') + + expect do + idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do + raise test_error + end + end.to raise_error('AppSignal error') + end + end + + context 'when Sentry is enabled' do + let(:mock_transaction) { double('Sentry::Transaction', finish: true) } + let(:mock_scope) { double('Sentry::Scope') } + + before do + stub_const('Sentry', double('Sentry')) + allow(Sentry).to receive(:start_transaction).and_return(mock_transaction) + allow(Sentry).to receive(:get_current_scope).and_return(mock_scope) + allow(mock_scope).to receive(:set_span) + + Idempotency.configure do |config| + config.redis_pool = redis_pool + config.logger = Logger.new(nil) + config.observability.appsignal_enabled = false + config.observability.sentry_enabled = true + end + end + + it 'wraps execution in Sentry transaction' do + expect(Sentry).to receive(:start_transaction).with( + name: 'test.operation', + op: 'idempotency', + action: 'test' + ).and_return(mock_transaction) + + expect(Sentry).to receive(:get_current_scope).and_return(mock_scope) + expect(mock_scope).to receive(:set_span).with(mock_transaction) + expect(mock_transaction).to receive(:finish) + + result = idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do + test_block.call + end + + expect(result).to eq(block_result) + end + + it 'captures exceptions with Sentry and re-raises' do + test_error = StandardError.new('test error') + + expect(Sentry).to receive(:start_transaction).with( + name: 'test.operation', + op: 'idempotency', + action: 'test' + ).and_return(mock_transaction) + + expect(Sentry).to receive(:get_current_scope).and_return(mock_scope) + expect(mock_scope).to receive(:set_span).with(mock_transaction) + expect(Sentry).to receive(:capture_exception).with(test_error) + expect(mock_transaction).to receive(:finish) + + expect do + idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do + raise test_error + end + end.to raise_error(StandardError, 'test error') + end + + it 'ensures transaction is finished even when error occurs' do + test_error = StandardError.new('test error') + + expect(Sentry).to receive(:start_transaction).and_return(mock_transaction) + expect(Sentry).to receive(:get_current_scope).and_return(mock_scope) + expect(mock_scope).to receive(:set_span).with(mock_transaction) + expect(Sentry).to receive(:capture_exception).with(test_error) + expect(mock_transaction).to receive(:finish) + + expect do + idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do + raise test_error + end + end.to raise_error(StandardError, 'test error') + end + end + + context 'when AppSignal is enabled but not defined' do + before do + hide_const('Appsignal') + + Idempotency.configure do |config| + config.redis_pool = redis_pool + config.logger = Logger.new(nil) + config.observability.appsignal_enabled = true + config.observability.sentry_enabled = false + end + end + + it 'falls back to executing without instrumentation' do + result = idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do + test_block.call + end + + expect(result).to eq(block_result) + end + end + + context 'when Sentry is enabled but not defined' do + before do + hide_const('Sentry') + + Idempotency.configure do |config| + config.redis_pool = redis_pool + config.logger = Logger.new(nil) + config.observability.appsignal_enabled = false + config.observability.sentry_enabled = true + end + end + + it 'falls back to executing without instrumentation' do + result = idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do + test_block.call + end + + expect(result).to eq(block_result) + end + end + + context 'when both AppSignal and Sentry are enabled' do + let(:mock_transaction) { double('Sentry::Transaction', finish: true) } + let(:mock_scope) { double('Sentry::Scope') } + + before do + stub_const('Appsignal', double('Appsignal')) + stub_const('Sentry', double('Sentry')) + allow(Sentry).to receive(:start_transaction).and_return(mock_transaction) + allow(Sentry).to receive(:get_current_scope).and_return(mock_scope) + allow(mock_scope).to receive(:set_span) + + Idempotency.configure do |config| + config.redis_pool = redis_pool + config.logger = Logger.new(nil) + config.observability.appsignal_enabled = true + config.observability.sentry_enabled = true + end + end + + it 'instruments in both AppSignal and Sentry (nested)' do + # Expect Sentry to be set up (inner layer) + expect(Sentry).to receive(:start_transaction).with( + name: 'test.operation', + op: 'idempotency', + action: 'test' + ).and_return(mock_transaction) + + expect(Sentry).to receive(:get_current_scope).and_return(mock_scope) + expect(mock_scope).to receive(:set_span).with(mock_transaction) + expect(mock_transaction).to receive(:finish) + + # Expect AppSignal to wrap everything (outer layer) + expect(Appsignal).to receive(:monitor_transaction).with( + 'test.operation', + { action: 'test' } + ).and_yield + + result = idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do + test_block.call + end + + expect(result).to eq(block_result) + end + + it 'reports errors to both AppSignal and Sentry' do + test_error = StandardError.new('test error') + + # Expect Sentry to capture the exception + expect(Sentry).to receive(:start_transaction).and_return(mock_transaction) + expect(Sentry).to receive(:get_current_scope).and_return(mock_scope) + expect(mock_scope).to receive(:set_span).with(mock_transaction) + expect(Sentry).to receive(:capture_exception).with(test_error) + expect(mock_transaction).to receive(:finish) + + # Expect AppSignal to also capture the exception + expect(Appsignal).to receive(:monitor_transaction).and_yield + expect(Appsignal).to receive(:set_error).with(test_error) + + expect do + idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do + raise test_error + end + end.to raise_error(StandardError, 'test error') + end + end + end +end From ee755e119eb8814d64f8aa7292ab39eb30497ebd Mon Sep 17 00:00:00 2001 From: HNKhoi2410 Date: Wed, 19 Nov 2025 15:38:16 +0700 Subject: [PATCH 2/4] update syntax for sentry instrument + Appsignal transaction name convension --- Gemfile.lock | 1 + idempotency.gemspec | 2 + lib/idempotency.rb | 39 +--- spec/idempotency/apm_instrumentation_spec.rb | 194 ++----------------- 4 files changed, 23 insertions(+), 213 deletions(-) diff --git a/Gemfile.lock b/Gemfile.lock index d56d88e..64594fa 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -2,6 +2,7 @@ PATH remote: . specs: idempotency (0.3.0) + appsignal (>= 1.3.0) base64 dry-configurable dry-monitor 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 76c6ec8..f8a9e48 100644 --- a/lib/idempotency.rb +++ b/lib/idempotency.rb @@ -8,8 +8,7 @@ require_relative 'idempotency/instrumentation/statsd_listener' require 'dry-monitor' -# rubocop:disable Metrics/ClassLength -class Idempotency +class Idempotency # rubocop:disable Metrics/ClassLength extend Dry::Configurable @monitor = Monitor.new @@ -31,7 +30,6 @@ def self.notifier setting :observability do setting :appsignal_enabled, default: false - setting :sentry_enabled, default: false end setting :default_lock_expiry, default: 300 # 5 minutes @@ -71,7 +69,7 @@ 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}" - with_apm_instrumentation('idempotency.use_cache', action: action_name) do + with_apm_instrumentation('idempotency.use_cache', action_name) do return yield unless cache_request?(request) request_headers = request.env @@ -149,41 +147,16 @@ def unquote(str) end end - # rubocop:disable Metrics/AbcSize - def with_apm_instrumentation(name, tags = {}, &block) - # Build nested instrumentation layers from innermost to outermost - instrumented_block = block - - # Wrap with Sentry if enabled - if config.observability.sentry_enabled && defined?(Sentry) - instrumented_block = lambda do - transaction = Sentry.start_transaction(name: name, op: 'idempotency', **tags) - Sentry.get_current_scope.set_span(transaction) - - begin - block.call - rescue StandardError => e - Sentry.capture_exception(e) - raise - ensure - transaction.finish - end - end - end - - # Wrap with AppSignal if enabled (outermost layer) + def with_apm_instrumentation(name, action, &) if config.observability.appsignal_enabled && defined?(Appsignal) - Appsignal.monitor_transaction(name, tags) do - instrumented_block.call + Appsignal.instrument(name, action) do + yield rescue StandardError => e Appsignal.set_error(e) raise end else - # Execute the (potentially Sentry-wrapped) block - instrumented_block.call + yield end end - # rubocop:enable Metrics/AbcSize end -# rubocop:enable Metrics/ClassLength diff --git a/spec/idempotency/apm_instrumentation_spec.rb b/spec/idempotency/apm_instrumentation_spec.rb index e95cc43..1a907cf 100644 --- a/spec/idempotency/apm_instrumentation_spec.rb +++ b/spec/idempotency/apm_instrumentation_spec.rb @@ -20,18 +20,17 @@ let(:block_result) { 'test_result' } let(:test_block) { -> { block_result } } - context 'when neither AppSignal nor Sentry is enabled' do + 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 - config.observability.sentry_enabled = false end end it 'executes the block without instrumentation' do - result = idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do + result = idempotency.send(:with_apm_instrumentation, 'test.operation', 'test') do test_block.call end @@ -42,22 +41,21 @@ 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 - config.observability.sentry_enabled = false end end - it 'wraps execution in AppSignal transaction' do - expect(Appsignal).to receive(:monitor_transaction).with( - 'test.operation', - { action: 'test' } + 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', action: 'test') do + result = idempotency.send(:with_apm_instrumentation, 'test.operation', 'test') do test_block.call end @@ -67,15 +65,14 @@ it 'reports errors to AppSignal and re-raises' do test_error = StandardError.new('test error') - expect(Appsignal).to receive(:monitor_transaction).with( - 'test.operation', - { action: 'test' } + expect(Appsignal).to receive(:instrument).with( + 'test.operation', 'test' ).and_yield expect(Appsignal).to receive(:set_error).with(test_error) expect do - idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do + idempotency.send(:with_apm_instrumentation, 'test.operation', 'test') do raise test_error end end.to raise_error(StandardError, 'test error') @@ -84,95 +81,20 @@ it 'handles exceptions during error reporting gracefully' do test_error = StandardError.new('test error') - expect(Appsignal).to receive(:monitor_transaction).with( - 'test.operation', - { action: 'test' } + expect(Appsignal).to receive(:instrument).with( + 'test.operation', 'test' ).and_yield expect(Appsignal).to receive(:set_error).with(test_error).and_raise('AppSignal error') expect do - idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do + idempotency.send(:with_apm_instrumentation, 'test.operation', 'test') do raise test_error end end.to raise_error('AppSignal error') end end - context 'when Sentry is enabled' do - let(:mock_transaction) { double('Sentry::Transaction', finish: true) } - let(:mock_scope) { double('Sentry::Scope') } - - before do - stub_const('Sentry', double('Sentry')) - allow(Sentry).to receive(:start_transaction).and_return(mock_transaction) - allow(Sentry).to receive(:get_current_scope).and_return(mock_scope) - allow(mock_scope).to receive(:set_span) - - Idempotency.configure do |config| - config.redis_pool = redis_pool - config.logger = Logger.new(nil) - config.observability.appsignal_enabled = false - config.observability.sentry_enabled = true - end - end - - it 'wraps execution in Sentry transaction' do - expect(Sentry).to receive(:start_transaction).with( - name: 'test.operation', - op: 'idempotency', - action: 'test' - ).and_return(mock_transaction) - - expect(Sentry).to receive(:get_current_scope).and_return(mock_scope) - expect(mock_scope).to receive(:set_span).with(mock_transaction) - expect(mock_transaction).to receive(:finish) - - result = idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do - test_block.call - end - - expect(result).to eq(block_result) - end - - it 'captures exceptions with Sentry and re-raises' do - test_error = StandardError.new('test error') - - expect(Sentry).to receive(:start_transaction).with( - name: 'test.operation', - op: 'idempotency', - action: 'test' - ).and_return(mock_transaction) - - expect(Sentry).to receive(:get_current_scope).and_return(mock_scope) - expect(mock_scope).to receive(:set_span).with(mock_transaction) - expect(Sentry).to receive(:capture_exception).with(test_error) - expect(mock_transaction).to receive(:finish) - - expect do - idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do - raise test_error - end - end.to raise_error(StandardError, 'test error') - end - - it 'ensures transaction is finished even when error occurs' do - test_error = StandardError.new('test error') - - expect(Sentry).to receive(:start_transaction).and_return(mock_transaction) - expect(Sentry).to receive(:get_current_scope).and_return(mock_scope) - expect(mock_scope).to receive(:set_span).with(mock_transaction) - expect(Sentry).to receive(:capture_exception).with(test_error) - expect(mock_transaction).to receive(:finish) - - expect do - idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do - raise test_error - end - end.to raise_error(StandardError, 'test error') - end - end - context 'when AppSignal is enabled but not defined' do before do hide_const('Appsignal') @@ -181,104 +103,16 @@ config.redis_pool = redis_pool config.logger = Logger.new(nil) config.observability.appsignal_enabled = true - config.observability.sentry_enabled = false - end - end - - it 'falls back to executing without instrumentation' do - result = idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do - test_block.call - end - - expect(result).to eq(block_result) - end - end - - context 'when Sentry is enabled but not defined' do - before do - hide_const('Sentry') - - Idempotency.configure do |config| - config.redis_pool = redis_pool - config.logger = Logger.new(nil) - config.observability.appsignal_enabled = false - config.observability.sentry_enabled = true end end it 'falls back to executing without instrumentation' do - result = idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do - test_block.call - end - - expect(result).to eq(block_result) - end - end - - context 'when both AppSignal and Sentry are enabled' do - let(:mock_transaction) { double('Sentry::Transaction', finish: true) } - let(:mock_scope) { double('Sentry::Scope') } - - before do - stub_const('Appsignal', double('Appsignal')) - stub_const('Sentry', double('Sentry')) - allow(Sentry).to receive(:start_transaction).and_return(mock_transaction) - allow(Sentry).to receive(:get_current_scope).and_return(mock_scope) - allow(mock_scope).to receive(:set_span) - - Idempotency.configure do |config| - config.redis_pool = redis_pool - config.logger = Logger.new(nil) - config.observability.appsignal_enabled = true - config.observability.sentry_enabled = true - end - end - - it 'instruments in both AppSignal and Sentry (nested)' do - # Expect Sentry to be set up (inner layer) - expect(Sentry).to receive(:start_transaction).with( - name: 'test.operation', - op: 'idempotency', - action: 'test' - ).and_return(mock_transaction) - - expect(Sentry).to receive(:get_current_scope).and_return(mock_scope) - expect(mock_scope).to receive(:set_span).with(mock_transaction) - expect(mock_transaction).to receive(:finish) - - # Expect AppSignal to wrap everything (outer layer) - expect(Appsignal).to receive(:monitor_transaction).with( - 'test.operation', - { action: 'test' } - ).and_yield - - result = idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do + result = idempotency.send(:with_apm_instrumentation, 'test.operation', 'test') do test_block.call end expect(result).to eq(block_result) end - - it 'reports errors to both AppSignal and Sentry' do - test_error = StandardError.new('test error') - - # Expect Sentry to capture the exception - expect(Sentry).to receive(:start_transaction).and_return(mock_transaction) - expect(Sentry).to receive(:get_current_scope).and_return(mock_scope) - expect(mock_scope).to receive(:set_span).with(mock_transaction) - expect(Sentry).to receive(:capture_exception).with(test_error) - expect(mock_transaction).to receive(:finish) - - # Expect AppSignal to also capture the exception - expect(Appsignal).to receive(:monitor_transaction).and_yield - expect(Appsignal).to receive(:set_error).with(test_error) - - expect do - idempotency.send(:with_apm_instrumentation, 'test.operation', action: 'test') do - raise test_error - end - end.to raise_error(StandardError, 'test error') - end end end end From d1dc81e0e5822fee25ca9027fc28817c1ae50c17 Mon Sep 17 00:00:00 2001 From: HNKhoi2410 Date: Wed, 19 Nov 2025 16:08:54 +0700 Subject: [PATCH 3/4] update to remove rescue block --- Gemfile | 3 +- Gemfile.lock | 7 +-- lib/idempotency.rb | 5 +- spec/idempotency/apm_instrumentation_spec.rb | 52 -------------------- 4 files changed, 3 insertions(+), 64 deletions(-) diff --git a/Gemfile b/Gemfile index 1897aa2..1be1fcf 100644 --- a/Gemfile +++ b/Gemfile @@ -15,5 +15,4 @@ gem 'pry-byebug' gem 'rubocop', '~> 1.21' # Optional observability integrations for testing -gem 'appsignal', '>= 2.0', '< 4.0' -gem 'sentry-ruby', '>= 4.1.0' +gem 'appsignal', '>= 1.3.0' diff --git a/Gemfile.lock b/Gemfile.lock index 64594fa..3052d84 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -16,7 +16,6 @@ GEM rack ast (2.4.2) base64 (0.2.0) - bigdecimal (3.3.1) byebug (11.1.3) coderay (1.1.3) concurrent-ruby (1.3.4) @@ -92,9 +91,6 @@ GEM rubocop-ast (1.35.0) parser (>= 3.3.1.0) ruby-progressbar (1.13.0) - sentry-ruby (6.1.0) - bigdecimal - concurrent-ruby (~> 1.0, >= 1.0.2) transproc (1.1.1) unicode-display_width (2.6.0) zeitwerk (2.6.18) @@ -104,7 +100,7 @@ PLATFORMS ruby DEPENDENCIES - appsignal (>= 2.0, < 4.0) + appsignal (>= 1.3.0) connection_pool dry-monitor hanami-controller (~> 1.3) @@ -113,7 +109,6 @@ DEPENDENCIES pry-byebug rspec (~> 3.0) rubocop (~> 1.21) - sentry-ruby (>= 4.1.0) BUNDLED WITH 2.5.11 diff --git a/lib/idempotency.rb b/lib/idempotency.rb index f8a9e48..89b5a54 100644 --- a/lib/idempotency.rb +++ b/lib/idempotency.rb @@ -148,12 +148,9 @@ def unquote(str) end def with_apm_instrumentation(name, action, &) - if config.observability.appsignal_enabled && defined?(Appsignal) + if config.observability.appsignal_enabled Appsignal.instrument(name, action) do yield - rescue StandardError => e - Appsignal.set_error(e) - raise end else yield diff --git a/spec/idempotency/apm_instrumentation_spec.rb b/spec/idempotency/apm_instrumentation_spec.rb index 1a907cf..427b25f 100644 --- a/spec/idempotency/apm_instrumentation_spec.rb +++ b/spec/idempotency/apm_instrumentation_spec.rb @@ -61,58 +61,6 @@ expect(result).to eq(block_result) end - - it 'reports errors to AppSignal and re-raises' do - test_error = StandardError.new('test error') - - expect(Appsignal).to receive(:instrument).with( - 'test.operation', 'test' - ).and_yield - - expect(Appsignal).to receive(:set_error).with(test_error) - - expect do - idempotency.send(:with_apm_instrumentation, 'test.operation', 'test') do - raise test_error - end - end.to raise_error(StandardError, 'test error') - end - - it 'handles exceptions during error reporting gracefully' do - test_error = StandardError.new('test error') - - expect(Appsignal).to receive(:instrument).with( - 'test.operation', 'test' - ).and_yield - - expect(Appsignal).to receive(:set_error).with(test_error).and_raise('AppSignal error') - - expect do - idempotency.send(:with_apm_instrumentation, 'test.operation', 'test') do - raise test_error - end - end.to raise_error('AppSignal error') - end - end - - context 'when AppSignal is enabled but not defined' do - before do - hide_const('Appsignal') - - Idempotency.configure do |config| - config.redis_pool = redis_pool - config.logger = Logger.new(nil) - config.observability.appsignal_enabled = true - end - end - - it 'falls back to executing 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 end end From c10e2be2fdcf1a29dc8bae84b48b328ef1606aba Mon Sep 17 00:00:00 2001 From: HNKhoi2410 Date: Thu, 20 Nov 2025 10:39:33 +0700 Subject: [PATCH 4/4] update readme --- README.md | 14 -------------- 1 file changed, 14 deletions(-) diff --git a/README.md b/README.md index e2765a0..1314d6e 100644 --- a/README.md +++ b/README.md @@ -154,20 +154,6 @@ end Note: The AppSignal gem must be installed and configured in your application. -#### Sentry - -The gem can add the `use_cache` method to Sentry performance traces when enabled. This allows you to see the idempotency check as part of your request traces and automatically captures any errors that occur. - -To enable Sentry transaction tracking: - -```ruby -Idempotency.configure do |config| - config.observability.sentry_enabled = true -end -``` - -Note: The Sentry 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: