Skip to content

Commit fe996d4

Browse files
HNKhoi2410claude
andcommitted
Add AppSignal and Sentry APM instrumentation (v0.3.0)
- 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 <noreply@anthropic.com>
1 parent 9dd02e3 commit fe996d4

File tree

7 files changed

+426
-25
lines changed

7 files changed

+426
-25
lines changed

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
## [Change Log]
22

3+
## [0.3.0] - 2025-11-14
4+
5+
- Add AppSignal integration for transaction tracking in trace stacks
6+
- Add Sentry integration for transaction tracking in trace stacks
7+
- Add observability configuration options (appsignal_enabled, sentry_enabled)
8+
39
## [0.2.0] - 2025-07-28
410

511
- Enforce explicit monkey-patch requirement

Gemfile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,7 @@ gem 'dry-monitor'
1313
gem 'hanami-controller', '~> 1.3'
1414
gem 'pry-byebug'
1515
gem 'rubocop', '~> 1.21'
16+
17+
# Optional observability integrations for testing
18+
gem 'appsignal', '>= 2.0', '< 4.0'
19+
gem 'sentry-ruby', '>= 4.1.0'

Gemfile.lock

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
PATH
22
remote: .
33
specs:
4-
idempotency (0.2.0)
4+
idempotency (0.3.0)
55
base64
66
dry-configurable
77
dry-monitor
@@ -11,8 +11,11 @@ PATH
1111
GEM
1212
remote: https://rubygems.org/
1313
specs:
14+
appsignal (3.13.1)
15+
rack
1416
ast (2.4.2)
1517
base64 (0.2.0)
18+
bigdecimal (3.3.1)
1619
byebug (11.1.3)
1720
coderay (1.1.3)
1821
concurrent-ruby (1.3.4)
@@ -88,6 +91,9 @@ GEM
8891
rubocop-ast (1.35.0)
8992
parser (>= 3.3.1.0)
9093
ruby-progressbar (1.13.0)
94+
sentry-ruby (6.1.0)
95+
bigdecimal
96+
concurrent-ruby (~> 1.0, >= 1.0.2)
9197
transproc (1.1.1)
9298
unicode-display_width (2.6.0)
9399
zeitwerk (2.6.18)
@@ -97,6 +103,7 @@ PLATFORMS
97103
ruby
98104

99105
DEPENDENCIES
106+
appsignal (>= 2.0, < 4.0)
100107
connection_pool
101108
dry-monitor
102109
hanami-controller (~> 1.3)
@@ -105,6 +112,7 @@ DEPENDENCIES
105112
pry-byebug
106113
rspec (~> 3.0)
107114
rubocop (~> 1.21)
115+
sentry-ruby (>= 4.1.0)
108116

109117
BUNDLED WITH
110118
2.5.11

README.md

Lines changed: 52 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,11 @@ Idempotency.configure do |config|
3434
config.metrics.statsd_client = statsd_client # Your StatsD client instance
3535
config.metrics.namespace = 'my_service_name' # Optional namespace for metrics
3636

37+
# APM/Observability configuration (optional) - adds method to trace stacks
38+
# You can enable one or both observability tools simultaneously
39+
config.observability.appsignal_enabled = true # Enable AppSignal transaction tracking
40+
config.observability.sentry_enabled = true # Enable Sentry transaction tracking
41+
3742
# Custom instrumentation listeners (optional)
3843
config.instrumentation_listeners = [my_custom_listener] # Array of custom listeners
3944
end
@@ -110,7 +115,11 @@ end
110115

111116
### Instrumentation
112117

113-
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:
118+
The gem supports instrumentation through multiple observability platforms:
119+
120+
#### StatsD
121+
122+
When you configure a StatsD client in the configuration, the StatsdListener will be automatically set up. It tracks the following metrics:
114123

115124
- `idempotency_cache_hit_count` - Incremented when a cached response is found
116125
- `idempotency_cache_miss_count` - Incremented when no cached response exists
@@ -122,11 +131,52 @@ Each metric includes tags:
122131
- `namespace` - Your configured namespace (if provided)
123132
- `metric` - The metric name (for duration histogram only)
124133

125-
To enable StatsD instrumentation, simply configure the metrics settings:
134+
To enable StatsD instrumentation:
126135

127136
```ruby
128137
Idempotency.configure do |config|
129138
config.metrics.statsd_client = Datadog::Statsd.new
130139
config.metrics.namespace = 'my_service_name'
131140
end
132141
```
142+
143+
#### AppSignal
144+
145+
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.
146+
147+
To enable AppSignal transaction tracking:
148+
149+
```ruby
150+
Idempotency.configure do |config|
151+
config.observability.appsignal_enabled = true
152+
end
153+
```
154+
155+
Note: The AppSignal gem must be installed and configured in your application.
156+
157+
#### Sentry
158+
159+
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.
160+
161+
To enable Sentry transaction tracking:
162+
163+
```ruby
164+
Idempotency.configure do |config|
165+
config.observability.sentry_enabled = true
166+
end
167+
```
168+
169+
Note: The Sentry gem must be installed and configured in your application.
170+
171+
#### Using Both AppSignal and Sentry
172+
173+
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:
174+
175+
```ruby
176+
Idempotency.configure do |config|
177+
config.observability.appsignal_enabled = true
178+
config.observability.sentry_enabled = true
179+
end
180+
```
181+
182+
This allows you to see the idempotency check in both your AppSignal and Sentry dashboards, providing comprehensive observability across your monitoring stack.

lib/idempotency.rb

Lines changed: 70 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
require_relative 'idempotency/instrumentation/statsd_listener'
99
require 'dry-monitor'
1010

11+
# rubocop:disable Metrics/ClassLength
1112
class Idempotency
1213
extend Dry::Configurable
1314
@monitor = Monitor.new
@@ -28,6 +29,11 @@ def self.notifier
2829
setting :statsd_client
2930
end
3031

32+
setting :observability do
33+
setting :appsignal_enabled, default: false
34+
setting :sentry_enabled, default: false
35+
end
36+
3137
setting :default_lock_expiry, default: 300 # 5 minutes
3238
setting :idempotent_methods, default: %w[POST PUT PATCH DELETE]
3339
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
6066
new.use_cache(request, request_identifiers, lock_duration:, action:, &blk)
6167
end
6268

63-
def use_cache(request, request_identifiers, lock_duration: nil, action: nil) # rubocop:disable Metrics/AbcSize
69+
# rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity
70+
def use_cache(request, request_identifiers, lock_duration: nil, action: nil)
6471
duration_start = Process.clock_gettime(::Process::CLOCK_MONOTONIC)
72+
action_name = action || "#{request.request_method}:#{request.path}"
6573

66-
return yield unless cache_request?(request)
74+
with_apm_instrumentation('idempotency.use_cache', action: action_name) do
75+
return yield unless cache_request?(request)
6776

68-
request_headers = request.env
69-
idempotency_key = unquote(request_headers[Constants::RACK_HEADER_KEY] || SecureRandom.hex)
77+
request_headers = request.env
78+
idempotency_key = unquote(request_headers[Constants::RACK_HEADER_KEY] || SecureRandom.hex)
7079

71-
fingerprint = calculate_fingerprint(request, idempotency_key, request_identifiers)
80+
fingerprint = calculate_fingerprint(request, idempotency_key, request_identifiers)
7281

73-
cached_response = cache.get(fingerprint)
82+
cached_response = cache.get(fingerprint)
7483

75-
if (cached_status, cached_headers, cached_body = cached_response)
76-
cached_headers.merge!(Constants::HEADER_KEY => idempotency_key)
77-
instrument(Events::CACHE_HIT, request:, action:, duration: calculate_duration(duration_start))
84+
if (cached_status, cached_headers, cached_body = cached_response)
85+
cached_headers.merge!(Constants::HEADER_KEY => idempotency_key)
86+
instrument(Events::CACHE_HIT, request:, action:, duration: calculate_duration(duration_start))
7887

79-
return [cached_status, cached_headers, cached_body]
80-
end
88+
return [cached_status, cached_headers, cached_body]
89+
end
8190

82-
lock_duration ||= config.default_lock_expiry
83-
response_status, response_headers, response_body = cache.with_lock(fingerprint, lock_duration) do
84-
yield
85-
end
91+
lock_duration ||= config.default_lock_expiry
92+
response_status, response_headers, response_body = cache.with_lock(fingerprint, lock_duration) do
93+
yield
94+
end
8695

87-
if cache_response?(response_status)
88-
cache.set(fingerprint, response_status, response_headers, response_body)
89-
response_headers.merge!({ Constants::HEADER_KEY => idempotency_key })
90-
end
96+
if cache_response?(response_status)
97+
cache.set(fingerprint, response_status, response_headers, response_body)
98+
response_headers.merge!({ Constants::HEADER_KEY => idempotency_key })
99+
end
91100

92-
instrument(Events::CACHE_MISS, request:, action:, duration: calculate_duration(duration_start))
93-
[response_status, response_headers, response_body]
101+
instrument(Events::CACHE_MISS, request:, action:, duration: calculate_duration(duration_start))
102+
[response_status, response_headers, response_body]
103+
end
94104
rescue Idempotency::Cache::LockConflict
95105
instrument(Events::LOCK_CONFLICT, request:, action:, duration: calculate_duration(duration_start))
96106
[409, {}, config.response_body.concurrent_error]
97107
end
108+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
98109

99110
private
100111

@@ -137,4 +148,42 @@ def unquote(str)
137148
str
138149
end
139150
end
151+
152+
# rubocop:disable Metrics/AbcSize
153+
def with_apm_instrumentation(name, tags = {}, &block)
154+
# Build nested instrumentation layers from innermost to outermost
155+
instrumented_block = block
156+
157+
# Wrap with Sentry if enabled
158+
if config.observability.sentry_enabled && defined?(Sentry)
159+
instrumented_block = lambda do
160+
transaction = Sentry.start_transaction(name: name, op: 'idempotency', **tags)
161+
Sentry.get_current_scope.set_span(transaction)
162+
163+
begin
164+
block.call
165+
rescue StandardError => e
166+
Sentry.capture_exception(e)
167+
raise
168+
ensure
169+
transaction.finish
170+
end
171+
end
172+
end
173+
174+
# Wrap with AppSignal if enabled (outermost layer)
175+
if config.observability.appsignal_enabled && defined?(Appsignal)
176+
Appsignal.monitor_transaction(name, tags) do
177+
instrumented_block.call
178+
rescue StandardError => e
179+
Appsignal.set_error(e)
180+
raise
181+
end
182+
else
183+
# Execute the (potentially Sentry-wrapped) block
184+
instrumented_block.call
185+
end
186+
end
187+
# rubocop:enable Metrics/AbcSize
140188
end
189+
# rubocop:enable Metrics/ClassLength

lib/idempotency/version.rb

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
# frozen_string_literal: true
22

33
class Idempotency
4-
VERSION = '0.2.0'
4+
VERSION = '0.3.0'
55
end

0 commit comments

Comments
 (0)