Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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
Expand Down
3 changes: 3 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
6 changes: 5 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
PATH
remote: .
specs:
idempotency (0.2.0)
idempotency (0.3.0)
appsignal (>= 1.3.0)
base64
dry-configurable
dry-monitor
Expand All @@ -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)
Expand Down Expand Up @@ -97,6 +100,7 @@ PLATFORMS
ruby

DEPENDENCIES
appsignal (>= 1.3.0)
connection_pool
dry-monitor
hanami-controller (~> 1.3)
Expand Down
40 changes: 38 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -122,11 +131,38 @@ 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|
config.metrics.statsd_client = Datadog::Statsd.new
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.
2 changes: 2 additions & 0 deletions idempotency.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -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
63 changes: 41 additions & 22 deletions lib/idempotency.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
2 changes: 1 addition & 1 deletion lib/idempotency/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

class Idempotency
VERSION = '0.2.0'
VERSION = '0.3.0'
end
66 changes: 66 additions & 0 deletions spec/idempotency/apm_instrumentation_spec.rb
Original file line number Diff line number Diff line change
@@ -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