Skip to content

Commit a3093f5

Browse files
authored
Merge pull request #19 from Kaligo/feature/add-apm-instrumentation
[CA-39] Add AppSignal instrumentation (v0.3.0)
2 parents 9dd02e3 + c10e2be commit a3093f5

File tree

8 files changed

+162
-26
lines changed

8 files changed

+162
-26
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: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,3 +13,6 @@ 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', '>= 1.3.0'

Gemfile.lock

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
PATH
22
remote: .
33
specs:
4-
idempotency (0.2.0)
4+
idempotency (0.3.0)
5+
appsignal (>= 1.3.0)
56
base64
67
dry-configurable
78
dry-monitor
@@ -11,6 +12,8 @@ PATH
1112
GEM
1213
remote: https://rubygems.org/
1314
specs:
15+
appsignal (3.13.1)
16+
rack
1417
ast (2.4.2)
1518
base64 (0.2.0)
1619
byebug (11.1.3)
@@ -97,6 +100,7 @@ PLATFORMS
97100
ruby
98101

99102
DEPENDENCIES
103+
appsignal (>= 1.3.0)
100104
connection_pool
101105
dry-monitor
102106
hanami-controller (~> 1.3)

README.md

Lines changed: 38 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,38 @@ 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+
#### Using Both AppSignal and Sentry
158+
159+
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:
160+
161+
```ruby
162+
Idempotency.configure do |config|
163+
config.observability.appsignal_enabled = true
164+
config.observability.sentry_enabled = true
165+
end
166+
```
167+
168+
This allows you to see the idempotency check in both your AppSignal and Sentry dashboards, providing comprehensive observability across your monitoring stack.

idempotency.gemspec

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,4 +39,6 @@ Gem::Specification.new do |spec|
3939
spec.add_dependency 'dry-monitor'
4040
spec.add_dependency 'msgpack'
4141
spec.add_dependency 'redis'
42+
43+
spec.add_dependency 'appsignal', '>= 1.3.0'
4244
end

lib/idempotency.rb

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

11-
class Idempotency
11+
class Idempotency # rubocop:disable Metrics/ClassLength
1212
extend Dry::Configurable
1313
@monitor = Monitor.new
1414

@@ -28,6 +28,10 @@ def self.notifier
2828
setting :statsd_client
2929
end
3030

31+
setting :observability do
32+
setting :appsignal_enabled, default: false
33+
end
34+
3135
setting :default_lock_expiry, default: 300 # 5 minutes
3236
setting :idempotent_methods, default: %w[POST PUT PATCH DELETE]
3337
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
6064
new.use_cache(request, request_identifiers, lock_duration:, action:, &blk)
6165
end
6266

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

66-
return yield unless cache_request?(request)
72+
with_apm_instrumentation('idempotency.use_cache', action_name) do
73+
return yield unless cache_request?(request)
6774

68-
request_headers = request.env
69-
idempotency_key = unquote(request_headers[Constants::RACK_HEADER_KEY] || SecureRandom.hex)
75+
request_headers = request.env
76+
idempotency_key = unquote(request_headers[Constants::RACK_HEADER_KEY] || SecureRandom.hex)
7077

71-
fingerprint = calculate_fingerprint(request, idempotency_key, request_identifiers)
78+
fingerprint = calculate_fingerprint(request, idempotency_key, request_identifiers)
7279

73-
cached_response = cache.get(fingerprint)
80+
cached_response = cache.get(fingerprint)
7481

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))
82+
if (cached_status, cached_headers, cached_body = cached_response)
83+
cached_headers.merge!(Constants::HEADER_KEY => idempotency_key)
84+
instrument(Events::CACHE_HIT, request:, action:, duration: calculate_duration(duration_start))
7885

79-
return [cached_status, cached_headers, cached_body]
80-
end
86+
return [cached_status, cached_headers, cached_body]
87+
end
8188

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
89+
lock_duration ||= config.default_lock_expiry
90+
response_status, response_headers, response_body = cache.with_lock(fingerprint, lock_duration) do
91+
yield
92+
end
8693

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
94+
if cache_response?(response_status)
95+
cache.set(fingerprint, response_status, response_headers, response_body)
96+
response_headers.merge!({ Constants::HEADER_KEY => idempotency_key })
97+
end
9198

92-
instrument(Events::CACHE_MISS, request:, action:, duration: calculate_duration(duration_start))
93-
[response_status, response_headers, response_body]
99+
instrument(Events::CACHE_MISS, request:, action:, duration: calculate_duration(duration_start))
100+
[response_status, response_headers, response_body]
101+
end
94102
rescue Idempotency::Cache::LockConflict
95103
instrument(Events::LOCK_CONFLICT, request:, action:, duration: calculate_duration(duration_start))
96104
[409, {}, config.response_body.concurrent_error]
97105
end
106+
# rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity
98107

99108
private
100109

@@ -137,4 +146,14 @@ def unquote(str)
137146
str
138147
end
139148
end
149+
150+
def with_apm_instrumentation(name, action, &)
151+
if config.observability.appsignal_enabled
152+
Appsignal.instrument(name, action) do
153+
yield
154+
end
155+
else
156+
yield
157+
end
158+
end
140159
end

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
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
# frozen_string_literal: true
2+
3+
RSpec.describe 'Idempotency APM Instrumentation' do
4+
let(:mock_redis) { MockRedis.new }
5+
let(:redis_pool) { ConnectionPool.new { mock_redis } }
6+
let(:idempotency) { Idempotency.new }
7+
8+
before do
9+
Idempotency.configure do |config|
10+
config.redis_pool = redis_pool
11+
config.logger = Logger.new(nil)
12+
end
13+
end
14+
15+
after do
16+
Idempotency.reset_config
17+
end
18+
19+
describe '#with_apm_instrumentation' do
20+
let(:block_result) { 'test_result' }
21+
let(:test_block) { -> { block_result } }
22+
23+
context 'when AppSignal is not enabled' do
24+
before do
25+
Idempotency.configure do |config|
26+
config.redis_pool = redis_pool
27+
config.logger = Logger.new(nil)
28+
config.observability.appsignal_enabled = false
29+
end
30+
end
31+
32+
it 'executes the block without instrumentation' do
33+
result = idempotency.send(:with_apm_instrumentation, 'test.operation', 'test') do
34+
test_block.call
35+
end
36+
37+
expect(result).to eq(block_result)
38+
end
39+
end
40+
41+
context 'when AppSignal is enabled' do
42+
before do
43+
stub_const('Appsignal', double('Appsignal'))
44+
allow(Appsignal).to receive(:instrument).and_yield
45+
46+
Idempotency.configure do |config|
47+
config.redis_pool = redis_pool
48+
config.logger = Logger.new(nil)
49+
config.observability.appsignal_enabled = true
50+
end
51+
end
52+
53+
it 'wraps execution in AppSignal instrumentation' do
54+
expect(Appsignal).to receive(:instrument).with(
55+
'test.operation', 'test'
56+
).and_yield
57+
58+
result = idempotency.send(:with_apm_instrumentation, 'test.operation', 'test') do
59+
test_block.call
60+
end
61+
62+
expect(result).to eq(block_result)
63+
end
64+
end
65+
end
66+
end

0 commit comments

Comments
 (0)