Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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'
10 changes: 9 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
PATH
remote: .
specs:
idempotency (0.2.0)
idempotency (0.3.0)
base64
dry-configurable
dry-monitor
Expand All @@ -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)
Expand Down Expand Up @@ -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)
Expand All @@ -97,6 +103,7 @@ PLATFORMS
ruby

DEPENDENCIES
appsignal (>= 2.0, < 4.0)
connection_pool
dry-monitor
hanami-controller (~> 1.3)
Expand All @@ -105,6 +112,7 @@ DEPENDENCIES
pry-byebug
rspec (~> 3.0)
rubocop (~> 1.21)
sentry-ruby (>= 4.1.0)

BUNDLED WITH
2.5.11
54 changes: 52 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,52 @@ 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.

#### 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
```
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

forgot to remove Sentry-related info here :keke:


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.
91 changes: 70 additions & 21 deletions lib/idempotency.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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)
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could we do the Sentry check at load time? Like when sentry_enabled is turned on 🤔

Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

At that point, we may require the library & check for version to ensure that we don't use outdated version.

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
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
Loading