idempotency 0.1.3 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 0f09805d7d2ec4b6d8e56f0d6ef0cc298dbe1a7df27e8cc542a45e401792b653
4
- data.tar.gz: 7117efd37aed82f6e4edea29a2c7b45357262e3e31b2e1a7e348a37521c11af8
3
+ metadata.gz: '00195248b99a69659167bae866e907aec9f44421d918bef44722ee79619692e7'
4
+ data.tar.gz: 17855572f61d3c2edca50a49b316dcd6a6dc5deaeb9067edeaf3005cb150b8bd
5
5
  SHA512:
6
- metadata.gz: 400b2ed412cad240f18e305c1ce57cc80a96e90f5103db11f1bcb8053734c819c976512058ee9c72eff74891b817524e86d525815b32ef50642670ddffc16a54
7
- data.tar.gz: e7924c51b71ac88ec1103888f45a43e9aa77da54f84395472b3b188a4c9f443045113d97385b7416c9e1cfb8bd364ed7daf95229c28c9800caf92df83e17cace
6
+ metadata.gz: 96fd4a1711a56f0a1dd2e3397e35eadc3b205bec1fa6e1dc9bf07a51258c5e511d262832fa13f364759394e16f9eb40cf4a63f73ecc8b30f247cf4e30ff6b2e6
7
+ data.tar.gz: 4eb3cba2a688b36aae230a8a024d12626556829ad3959264fd19abd29b10cf7a181bdbcc3906b67e30938a470c633ab00ebb1d31b3eb114076f8771a43985fef
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  ## [Change Log]
2
2
 
3
+ ## [0.1.4] - 2025-01-14
4
+
5
+ - Support metrics logging via StatsdListener
6
+
7
+ ## [0.1.3] - 2025-01-14
8
+
9
+ - Support testing by mocking MockRedis#evalsha
10
+
11
+ ## [0.1.2] - 2025-01-07
12
+
13
+ - Support auto-releasing gem
14
+
15
+ ## [0.1.1] - 2025-01-07
16
+
17
+ - Allow configuring idempotent methods instead of hardcoding POST
18
+
3
19
  ## [0.1.0] - 2024-11-13
4
20
 
5
21
  - Initial release
data/Gemfile CHANGED
@@ -9,6 +9,7 @@ gem 'mock_redis'
9
9
  gem 'rspec', '~> 3.0'
10
10
 
11
11
  gem 'connection_pool'
12
+ gem 'dry-monitor'
12
13
  gem 'hanami-controller', '~> 1.3'
13
14
  gem 'pry-byebug'
14
15
  gem 'rubocop', '~> 1.21'
data/Gemfile.lock CHANGED
@@ -1,9 +1,10 @@
1
1
  PATH
2
2
  remote: .
3
3
  specs:
4
- idempotency (0.1.3)
4
+ idempotency (0.1.4)
5
5
  base64
6
6
  dry-configurable
7
+ dry-monitor
7
8
  msgpack
8
9
  redis
9
10
 
@@ -24,6 +25,13 @@ GEM
24
25
  concurrent-ruby (~> 1.0)
25
26
  logger
26
27
  zeitwerk (~> 2.6)
28
+ dry-events (1.0.1)
29
+ concurrent-ruby (~> 1.0)
30
+ dry-core (~> 1.0, < 2)
31
+ dry-monitor (1.0.1)
32
+ dry-configurable (~> 1.0, < 2)
33
+ dry-core (~> 1.0, < 2)
34
+ dry-events (~> 1.0, < 2)
27
35
  hanami-controller (1.3.3)
28
36
  hanami-utils (~> 1.3)
29
37
  rack (~> 2.0)
@@ -90,6 +98,7 @@ PLATFORMS
90
98
 
91
99
  DEPENDENCIES
92
100
  connection_pool
101
+ dry-monitor
93
102
  hanami-controller (~> 1.3)
94
103
  idempotency!
95
104
  mock_redis
data/README.md CHANGED
@@ -28,7 +28,14 @@ Idempotency.configure do |config|
28
28
  }
29
29
 
30
30
  config.idempotent_methods = %w[POST PUT PATCH]
31
- config.idempotent_statuses = (200..299).to_a
31
+ config.idempotent_statuses = (200..299).to_a + (400..499).to_a
32
+
33
+ # Metrics configuration
34
+ config.metrics.statsd_client = statsd_client # Your StatsD client instance
35
+ config.metrics.namespace = 'my_service_name' # Optional namespace for metrics
36
+
37
+ # Custom instrumentation listeners (optional)
38
+ config.instrumentation_listeners = [my_custom_listener] # Array of custom listeners
32
39
  end
33
40
  ```
34
41
 
@@ -100,3 +107,25 @@ RSpec.configure do |config|
100
107
  end
101
108
  ```
102
109
 
110
+ ### Instrumentation
111
+
112
+ 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:
113
+
114
+ - `idempotency_cache_hit_count` - Incremented when a cached response is found
115
+ - `idempotency_cache_miss_count` - Incremented when no cached response exists
116
+ - `idempotency_lock_conflict_count` - Incremented when concurrent requests conflict
117
+ - `idempotency_cache_duration_seconds` - Histogram of operation duration
118
+
119
+ Each metric includes tags:
120
+ - `action` - Either the specified action name or `"{HTTP_METHOD}:{PATH}"`
121
+ - `namespace` - Your configured namespace (if provided)
122
+ - `metric` - The metric name (for duration histogram only)
123
+
124
+ To enable StatsD instrumentation, simply configure the metrics settings:
125
+
126
+ ```ruby
127
+ Idempotency.configure do |config|
128
+ config.metrics.statsd_client = Datadog::Statsd.new
129
+ config.metrics.namespace = 'my_service_name'
130
+ end
131
+ ```
data/idempotency.gemspec CHANGED
@@ -36,6 +36,7 @@ Gem::Specification.new do |spec|
36
36
 
37
37
  spec.add_dependency 'base64'
38
38
  spec.add_dependency 'dry-configurable'
39
+ spec.add_dependency 'dry-monitor'
39
40
  spec.add_dependency 'msgpack'
40
41
  spec.add_dependency 'redis'
41
42
  end
@@ -5,4 +5,12 @@ class Idempotency
5
5
  RACK_HEADER_KEY = 'HTTP_IDEMPOTENCY_KEY'
6
6
  HEADER_KEY = 'Idempotency-Key'
7
7
  end
8
+
9
+ module Events
10
+ ALL_EVENTS = [
11
+ CACHE_HIT = :cache_hit,
12
+ CACHE_MISS = :cache_miss,
13
+ LOCK_CONFLICT = :lock_conflict
14
+ ].freeze
15
+ end
8
16
  end
@@ -4,9 +4,9 @@ require_relative '../idempotency'
4
4
 
5
5
  class Idempotency
6
6
  module Hanami
7
- def use_cache(request_identifiers = [], lock_duration: nil)
7
+ def use_cache(request_identifiers = [], lock_duration: nil, action: self.class.name)
8
8
  response_status, response_headers, response_body = Idempotency.use_cache(
9
- request, request_identifiers, lock_duration:
9
+ request, request_identifiers, lock_duration:, action:
10
10
  ) do
11
11
  yield
12
12
 
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative '../../idempotency'
4
+
5
+ class Idempotency
6
+ module Instrumentation
7
+ class StatsdListener
8
+ EVENT_NAME_TO_METRIC_MAPPINGS = {
9
+ Events::CACHE_HIT => 'idempotency_cache_hit_count',
10
+ Events::CACHE_MISS => 'idempotency_cache_miss_count',
11
+ Events::LOCK_CONFLICT => 'idempotency_lock_conflict_count'
12
+ }.freeze
13
+
14
+ def initialize(statsd_client, namespace = nil)
15
+ @statsd_client = statsd_client
16
+ @namespace = namespace
17
+ end
18
+
19
+ def setup_subscriptions
20
+ EVENT_NAME_TO_METRIC_MAPPINGS.each do |event_name, metric|
21
+ Idempotency.notifier.subscribe(event_name) do |event|
22
+ send_metric(metric, event.payload)
23
+ end
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ attr_reader :namespace, :statsd_client
30
+
31
+ def send_metric(metric_name, event_data)
32
+ action = event_data[:action] || "#{event_data[:request].request_method}:#{event_data[:request].path}"
33
+ tags = ["action:#{action}"]
34
+ tags << "namespace:#{@namespace}" if @namespace
35
+
36
+ @statsd_client.increment(metric_name, tags:)
37
+ @statsd_client.histogram(
38
+ 'idempotency_cache_duration_seconds', event_data[:duration], tags: tags + ["metric:#{metric_name}"]
39
+ )
40
+ end
41
+ end
42
+ end
43
+ end
@@ -4,9 +4,9 @@ require_relative '../idempotency'
4
4
 
5
5
  class Idempotency
6
6
  module Rails
7
- def use_cache(request_identifiers = [], lock_duration: nil)
7
+ def use_cache(request_identifiers = [], lock_duration: nil, action: "#{controller_name}##{action_name}")
8
8
  response_status, response_headers, response_body = Idempotency.use_cache(
9
- request, request_identifiers, lock_duration:
9
+ request, request_identifiers, lock_duration:, action:
10
10
  ) do
11
11
  yield
12
12
 
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Idempotency
4
- VERSION = '0.1.3'
4
+ VERSION = '0.1.4'
5
5
  end
data/lib/idempotency.rb CHANGED
@@ -5,12 +5,29 @@ require 'json'
5
5
  require 'base64'
6
6
  require_relative 'idempotency/cache'
7
7
  require_relative 'idempotency/constants'
8
+ require_relative 'idempotency/instrumentation/statsd_listener'
9
+ require 'dry-monitor'
8
10
 
9
11
  class Idempotency
10
12
  extend Dry::Configurable
13
+ @monitor = Monitor.new
14
+
15
+ def self.notifier
16
+ @monitor.synchronize do
17
+ @notifier ||= Dry::Monitor::Notifications.new(:idempotency_gem).tap do |n|
18
+ Events::ALL_EVENTS.each { |event| n.register_event(event) }
19
+ end
20
+ end
21
+ end
11
22
 
12
23
  setting :redis_pool
13
24
  setting :logger
25
+ setting :instrumentation_listeners, default: []
26
+ setting :metrics do
27
+ setting :namespace
28
+ setting :statsd_client
29
+ end
30
+
14
31
  setting :default_lock_expiry, default: 300 # 5 minutes
15
32
  setting :idempotent_methods, default: %w[POST PUT PATCH DELETE]
16
33
  setting :idempotent_statuses, default: (200..299).to_a + (400..499).to_a
@@ -21,16 +38,31 @@ class Idempotency
21
38
  }.to_json
22
39
  end
23
40
 
41
+ def self.configure
42
+ super
43
+
44
+ if config.metrics.statsd_client
45
+ config.instrumentation_listeners << Idempotency::Instrumentation::StatsdListener.new(
46
+ config.metrics.statsd_client,
47
+ config.metrics.namespace
48
+ )
49
+ end
50
+
51
+ config.instrumentation_listeners.each(&:setup_subscriptions)
52
+ end
53
+
24
54
  def initialize(config: Idempotency.config, cache: Cache.new(config:))
25
55
  @config = config
26
56
  @cache = cache
27
57
  end
28
58
 
29
- def self.use_cache(request, request_identifiers, lock_duration: nil, &blk)
30
- new.use_cache(request, request_identifiers, lock_duration:, &blk)
59
+ def self.use_cache(request, request_identifiers, lock_duration: nil, action: nil, &blk)
60
+ new.use_cache(request, request_identifiers, lock_duration:, action:, &blk)
31
61
  end
32
62
 
33
- def use_cache(request, request_identifiers, lock_duration:) # rubocop:disable Metrics/AbcSize
63
+ def use_cache(request, request_identifiers, lock_duration: nil, action: nil) # rubocop:disable Metrics/AbcSize
64
+ duration_start = Process.clock_gettime(::Process::CLOCK_MONOTONIC)
65
+
34
66
  return yield unless cache_request?(request)
35
67
 
36
68
  request_headers = request.env
@@ -42,6 +74,8 @@ class Idempotency
42
74
 
43
75
  if (cached_status, cached_headers, cached_body = cached_response)
44
76
  cached_headers.merge!(Constants::HEADER_KEY => idempotency_key)
77
+ instrument(Events::CACHE_HIT, request:, action:, duration: calculate_duration(duration_start))
78
+
45
79
  return [cached_status, cached_headers, cached_body]
46
80
  end
47
81
 
@@ -55,8 +89,10 @@ class Idempotency
55
89
  response_headers.merge!({ Constants::HEADER_KEY => idempotency_key })
56
90
  end
57
91
 
92
+ instrument(Events::CACHE_MISS, request:, action:, duration: calculate_duration(duration_start))
58
93
  [response_status, response_headers, response_body]
59
94
  rescue Idempotency::Cache::LockConflict
95
+ instrument(Events::LOCK_CONFLICT, request:, action:, duration: calculate_duration(duration_start))
60
96
  [409, {}, config.response_body.concurrent_error]
61
97
  end
62
98
 
@@ -64,6 +100,14 @@ class Idempotency
64
100
 
65
101
  attr_reader :config, :cache
66
102
 
103
+ def instrument(event_name, **metadata)
104
+ Idempotency.notifier.instrument(event_name, **metadata)
105
+ end
106
+
107
+ def calculate_duration(start_time)
108
+ Process.clock_gettime(::Process::CLOCK_MONOTONIC) - start_time
109
+ end
110
+
67
111
  def calculate_fingerprint(request, idempotency_key, request_identifiers)
68
112
  d = Digest::SHA256.new
69
113
  d << idempotency_key
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: idempotency
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Vu Hoang
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-01-14 00:00:00.000000000 Z
11
+ date: 2025-01-16 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: base64
@@ -38,6 +38,20 @@ dependencies:
38
38
  - - ">="
39
39
  - !ruby/object:Gem::Version
40
40
  version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: dry-monitor
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
41
55
  - !ruby/object:Gem::Dependency
42
56
  name: msgpack
43
57
  requirement: !ruby/object:Gem::Requirement
@@ -90,6 +104,7 @@ files:
90
104
  - lib/idempotency/cache.rb
91
105
  - lib/idempotency/constants.rb
92
106
  - lib/idempotency/hanami.rb
107
+ - lib/idempotency/instrumentation/statsd_listener.rb
93
108
  - lib/idempotency/rails.rb
94
109
  - lib/idempotency/testing/helpers.rb
95
110
  - lib/idempotency/version.rb