close_encounters 0.2.3 → 0.3.0

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: 67adeb4fe53671ebcc9e37f040245dd7650ec3afc83433e13415c290617ee356
4
- data.tar.gz: e7afee308898aa7a25cf3ddfaa652369c74fdb074c014054ee37438babe0b714
3
+ metadata.gz: 153aff29d52784d224dc23d3cb5606fe7a1478c2522bff75dc80678d236f3a47
4
+ data.tar.gz: c2de654d365fe8ec5330b2254b45a56029e75895a808c90fc261d9e0aae333b2
5
5
  SHA512:
6
- metadata.gz: e0131dcd0a979e6bf3583fe3b90c91b597defbf0f660b8bfaaf2f8d2988a3b1714177148a6cec10078f1a69b8175de7c27d6bccc29523001264ca8c6f667e381
7
- data.tar.gz: 117c8347983b35986897f55920c4d8472be867598901ca2e83bbf51f2560f083e8c6b9e27559f67c327faa66cbde30b93c1cdb19abd1e9356e792411992ff385
6
+ metadata.gz: 2e1105a5a609dd0a34a0bd1d14bae3f043ab6757e6fe4d0634ba03e3eb101238007da02aea099c892668bc0c5f93614074fbd1b82597730a1534f4595b00b59a
7
+ data.tar.gz: 775ff7eb0f7d5d61523e1b47eeb3da245ef4c27b5b9ed740f31d22d15d6550dd81caa2fd333a9d4a6e1aebef39cb2e88cc932206143532681de8081a8421af32
data/CHANGELOG.md CHANGED
@@ -5,18 +5,26 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](http://keepachangelog.com/)
6
6
  and this project adheres to [Semantic Versioning](http://semver.org/).
7
7
 
8
- ## [0.2.3] - 2025-09-08
8
+ ## [0.3.0] - 2026-07-24
9
9
 
10
- ### Fixed
11
-
12
- - Fix duplicate event creation in `scan` method when status and verification state are unchanged
10
+ ### Added
13
11
 
14
- ## [0.2.2] - 2025-09-04
12
+ - Publish an `event_recorded.close_encounters` notification whenever a status change is recorded (83ed88c)
13
+ - CloseEncounters.record and pluggable response adapters (Adapters::NetHTTP) for tracking any HTTP client (d86e4e8)
15
14
 
16
15
  ### Changed
17
16
 
18
- - Update handling of the same status for multiple requests when creating events.
17
+ - Update dependencies (reissue 0.5.1, standard, sqlite3, puma, and others) and migrate the SimpleCov config to 1.0 (83ed88c)
18
+ - Develop and release on Ruby 4.0.6; CI now tests on 3.4 and 4.0.6 (83ed88c)
19
+ - Align the release workflow with reissue's shared workflow, adding checksum generation and a dry-run input (83ed88c)
20
+
21
+ ### Deprecated
22
+
23
+ - CloseEncounters::Middleware, which tracked inbound requests; use CloseEncounters.record for outbound responses (d86e4e8)
19
24
 
20
25
  ### Fixed
21
26
 
22
- - Ensure that status is an integer when creating events and comparing results.
27
+ - The tracking middleware no longer breaks the request it observes, rebuilds its service map each request, and ignores services without a domain (83ed88c)
28
+ - The newest-event lookup is now deterministic on timestamp ties and backed by a composite index (e94afe5)
29
+
30
+ ## [0.2.4] - 2026-04-06
data/README.md CHANGED
@@ -15,33 +15,69 @@ Then when you have it installed and migrated you can start tracking events.
15
15
 
16
16
  ```ruby
17
17
  response = SomeThirdPartyService.call
18
- CloseEncounters.contact("SomeThirdPartyService", status: response.status.to_i, response.body)
18
+ CloseEncounters.contact("SomeThirdPartyService", status: response.status.to_i, response: response.body)
19
19
  ```
20
20
 
21
- If the services regularly returns 200 responses, no new events will be recorded.
21
+ If the service regularly returns 200 responses, no new events will be recorded.
22
22
  When it switches to a different status, a new event will be recorded.
23
23
 
24
24
  ```ruby
25
25
  CloseEncounters.status("SomeThirdPartyService") # => 200
26
- # evuntually you use `contact` and it records a 500 and you'll be able to get
26
+ # eventually you use `contact` and it records a 500 and you'll be able to get
27
27
  CloseEncounters.status("SomeThirdPartyService") # => 500
28
28
  ```
29
29
 
30
- ### Rack Middleware
30
+ ### Recording a response from any HTTP client
31
31
 
32
- Setup your middleware in your `config/application.rb` or `config/environments/production.rb`.
32
+ `CloseEncounters.record` records a contact straight from an HTTP client's
33
+ response object, using an *adapter* to read the status and body. This keeps the
34
+ gem independent of any particular HTTP library.
33
35
 
34
36
  ```ruby
35
- config.middleware.use CloseEncounters::Middleware
37
+ response = SomeThirdPartyService.call # a Net::HTTP response
38
+ CloseEncounters.record("SomeThirdPartyService", response, adapter: CloseEncounters::Adapters::NetHTTP)
36
39
  ```
37
40
 
38
- This will automatically track responses from third-party services when you store a "domain" key in the
39
- `connection_info` in the ParticipantService records.
41
+ Pass a `verifier` to record a verified scan instead of a plain contact:
40
42
 
41
- Alternatively, you can use the `auto_contact!` method to automatically turn on the rack middleware:
43
+ ```ruby
44
+ CloseEncounters.record("SomeService", response, adapter: CloseEncounters::Adapters::NetHTTP, verifier: my_verifier)
45
+ ```
46
+
47
+ An adapter is any object that responds to `status(response)` and
48
+ `body(response)`, so supporting another client is a few lines:
49
+
50
+ ```ruby
51
+ module FaradayAdapter
52
+ module_function
53
+ def status(response) = response.status
54
+ def body(response) = response.body
55
+ end
56
+
57
+ CloseEncounters.record("SomeApi", faraday_response, adapter: FaradayAdapter)
58
+ ```
59
+
60
+ ### Rack Middleware (deprecated)
61
+
62
+ > **Deprecated.** `CloseEncounters::Middleware` keys on the *inbound* request
63
+ > host (`SERVER_NAME`), so it tracks requests arriving at your app rather than
64
+ > the outbound responses this gem is meant to monitor. Track responses with
65
+ > `CloseEncounters.record` (above) instead. The middleware will be removed in a
66
+ > future release.
67
+
68
+ ### Reacting to status changes
69
+
70
+ Rather than polling `CloseEncounters.status`, you can subscribe to be notified
71
+ whenever a new event is recorded (by either `contact` or `scan`). A
72
+ notification is only published when an event is actually created — i.e. when
73
+ the status or verification signature changes.
42
74
 
43
75
  ```ruby
44
- CloseEncounters.auto_contact!
76
+ ActiveSupport::Notifications.subscribe("event_recorded.close_encounters") do |*args|
77
+ payload = ActiveSupport::Notifications::Event.new(*args).payload
78
+ # payload => { name:, service:, event:, status: }
79
+ AlertMailer.status_changed(payload[:name], payload[:status]).deliver_later
80
+ end
45
81
  ```
46
82
 
47
83
  ### TODO
@@ -67,5 +103,11 @@ Or install it yourself as:
67
103
  $ gem install close_encounters
68
104
  ```
69
105
 
106
+ ## Releasing
107
+
108
+ This project is managed with [Reissue](https://github.com/SOFware/reissue). Releases are automated via the [shared release workflow](https://github.com/SOFware/reissue/blob/main/.github/workflows/SHARED_WORKFLOW_README.md). Trigger a release by running the "Release gem to RubyGems.org" workflow from the Actions tab.
109
+
110
+ Changelog entries come from git trailers (`Added:`, `Fixed:`, …) on your commits and/or hand-edits to the `## [Unreleased]` section of `CHANGELOG.md`; Reissue merges both. See [CONTRIBUTING.md](CONTRIBUTING.md#changelog-entries).
111
+
70
112
  ## License
71
113
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
data/Rakefile CHANGED
@@ -20,6 +20,8 @@ require "reissue/gem"
20
20
 
21
21
  Reissue::Task.create :reissue do |task|
22
22
  task.version_file = "lib/close_encounters/version.rb"
23
+ task.fragment = :git # Use git trailers for changelog entries
24
+ task.push_finalize = :branch
23
25
  end
24
26
 
25
27
  require "standard/rake"
@@ -5,7 +5,9 @@ module CloseEncounters
5
5
  class_name: "CloseEncounters::ParticipantService",
6
6
  foreign_key: "close_encounters_participant_service_id"
7
7
 
8
- scope :newest, -> { order(created_at: :desc).limit(1) }
8
+ # id breaks ties so "newest" is deterministic when two events share a
9
+ # created_at (common under bulk inserts and sub-second traffic).
10
+ scope :newest, -> { order(created_at: :desc, id: :desc).limit(1) }
9
11
 
10
12
  unless ActiveRecord::Base.connection.adapter_name.downcase.include?("postgresql")
11
13
  serialize :metadata, coder: JSON
@@ -0,0 +1,18 @@
1
+ class AddServiceCreatedAtIndexToParticipantEvents < ActiveRecord::Migration[7.1]
2
+ disable_ddl_transaction!
3
+
4
+ def change
5
+ options = if self.class.adapter_name.match?(/postgres/i)
6
+ {algorithm: :concurrently}
7
+ else
8
+ {}
9
+ end
10
+
11
+ # Supports the hot "newest event for a service" lookup:
12
+ # WHERE close_encounters_participant_service_id = ? ORDER BY created_at DESC.
13
+ add_index :close_encounters_participant_events,
14
+ [:close_encounters_participant_service_id, :created_at],
15
+ name: "idx_ce_events_on_service_and_created_at",
16
+ **options
17
+ end
18
+ end
@@ -0,0 +1,14 @@
1
+ module CloseEncounters
2
+ module Adapters
3
+ # Adapter for Net::HTTP responses. An adapter is any object responding to
4
+ # #status(response) and #body(response); see CloseEncounters.record.
5
+ module NetHTTP
6
+ module_function
7
+
8
+ # Net::HTTP exposes the status as a String (e.g. "200").
9
+ def status(response) = response.code.to_i
10
+
11
+ def body(response) = response.body
12
+ end
13
+ end
14
+ end
@@ -7,6 +7,10 @@ module CloseEncounters
7
7
  g.test_framework :minitest, spec: true
8
8
  end
9
9
 
10
+ initializer "close_encounters.deprecator" do |app|
11
+ app.deprecators[:close_encounters] = CloseEncounters.deprecator if app.respond_to?(:deprecators)
12
+ end
13
+
10
14
  initializer "close_encounters.middleware" do |app|
11
15
  app.middleware.use CloseEncounters::Middleware if CloseEncounters.auto_contact?
12
16
  end
@@ -1,6 +1,12 @@
1
1
  module CloseEncounters
2
2
  class Middleware
3
3
  def initialize(app, tracker: CloseEncounters)
4
+ CloseEncounters.deprecator.warn(
5
+ "CloseEncounters::Middleware tracks inbound requests by host, not the " \
6
+ "outbound responses this gem is meant to monitor. Track a service's " \
7
+ "response with CloseEncounters.record(name, response, adapter:) instead. " \
8
+ "The middleware will be removed in a future release."
9
+ )
4
10
  @app = app
5
11
  @tracker = tracker
6
12
  end
@@ -19,13 +25,20 @@ module CloseEncounters
19
25
  if (name = participant_services[host])
20
26
  @tracker.contact(name, status:, response:)
21
27
  end
28
+ rescue => e
29
+ # Tracking must never break the request it is observing.
30
+ Rails.logger&.error("[CloseEncounters] middleware tracking failed: #{e.class}: #{e.message}")
31
+ nil
22
32
  end
23
33
 
34
+ # Built per request rather than memoized: the middleware is instantiated
35
+ # once per process, so caching here would never reflect services added or
36
+ # changed after boot.
24
37
  def participant_services
25
- @participant_services ||= CloseEncounters::ParticipantService.all
26
- .map do |service|
27
- [service.connection_info["domain"], service.name]
28
- end.to_h
38
+ CloseEncounters::ParticipantService.all.each_with_object({}) do |service, map|
39
+ domain = service.connection_info&.[]("domain")
40
+ map[domain] = service.name if domain
41
+ end
29
42
  end
30
43
  end
31
44
  end
@@ -1,3 +1,3 @@
1
1
  module CloseEncounters
2
- VERSION = "0.2.3"
2
+ VERSION = "0.3.0"
3
3
  end
@@ -7,6 +7,10 @@ module CloseEncounters
7
7
  autoload :ParticipantService, "close_encounters/participant_service"
8
8
  autoload :ParticipantEvent, "close_encounters/participant_event"
9
9
 
10
+ module Adapters
11
+ autoload :NetHTTP, "close_encounters/adapters/net_http"
12
+ end
13
+
10
14
  class Configuration
11
15
  attr_accessor :auto_contact, :verify_scan_statuses
12
16
 
@@ -20,6 +24,12 @@ module CloseEncounters
20
24
  @configuration ||= Configuration.new
21
25
  end
22
26
 
27
+ # Deprecator for CloseEncounters. Registered with the host app in the engine
28
+ # so warnings flow through the app's configured deprecation behavior.
29
+ def self.deprecator
30
+ @deprecator ||= ActiveSupport::Deprecation.new("a future release", "CloseEncounters")
31
+ end
32
+
23
33
  def self.configure
24
34
  yield(configuration)
25
35
  end
@@ -33,48 +43,125 @@ module CloseEncounters
33
43
  service = ParticipantService.find_by!(name:)
34
44
  status = status.to_i # Ensure status is always an integer
35
45
 
46
+ created = nil
36
47
  # Use a transaction with a lock to prevent race conditions
37
48
  service.with_lock do
38
49
  unless service.events.newest.pick(:status) == status
39
- service.events.create!(status: status, response:)
50
+ created = service.events.create!(status: status, response:)
40
51
  end
41
52
  end
53
+
54
+ # Instrument after the transaction commits so subscribers see a persisted event.
55
+ instrument(name, created) if created
56
+ created
57
+ end
58
+
59
+ # Record the outcome of an HTTP request to a service straight from the
60
+ # client's response object, using an adapter to read the status and body.
61
+ #
62
+ # An adapter is any object responding to #status(response) and
63
+ # #body(response); CloseEncounters::Adapters::NetHTTP ships for Net::HTTP.
64
+ # Delegates to #scan when a verifier is given, otherwise #contact.
65
+ #
66
+ # CloseEncounters.record("SomeService", response, adapter: Adapters::NetHTTP)
67
+ # CloseEncounters.record("SomeService", response, adapter: Adapters::NetHTTP, verifier: my_verifier)
68
+ #
69
+ # @param name [String] the name of the service
70
+ # @param response [Object] the HTTP client's response object
71
+ # @param adapter [#status, #body] reads the status and body from the response
72
+ # @param verifier [#call, #to_s, nil] when given, records a verified scan
73
+ def record(name, response, adapter:, verifier: nil)
74
+ status = adapter.status(response)
75
+ body = adapter.body(response)
76
+ if verifier
77
+ scan(name, status:, response: body, verifier:)
78
+ else
79
+ contact(name, status:, response: body)
80
+ end
42
81
  end
43
82
 
83
+ # Publish an ActiveSupport::Notifications event whenever a new
84
+ # ParticipantEvent is recorded, so consumers can react to status changes
85
+ # instead of polling. Subscribe with:
86
+ #
87
+ # ActiveSupport::Notifications.subscribe("event_recorded.close_encounters") do |*args|
88
+ # payload = ActiveSupport::Notifications::Event.new(*args).payload
89
+ # # payload => { name:, service:, event:, status: }
90
+ # end
91
+ def instrument(name, event)
92
+ ActiveSupport::Notifications.instrument(
93
+ "event_recorded.close_encounters",
94
+ name:, service: event.participant_service, event:, status: event.status
95
+ )
96
+ end
97
+ private_class_method :instrument
98
+
99
+ VERIFIED_SIGNATURE = "ok"
100
+ UNVERIFIED_SIGNATURE = "unverified"
101
+
44
102
  # Record a verification of a contact with a third party service where the
45
103
  # verification is a callable which must also respond to to_s.
46
104
  #
105
+ # The verifier may return:
106
+ # * true / :ok — response is verified
107
+ # * false / nil — response failed verification (generic)
108
+ # * any other value — failed verification with a distinguishing
109
+ # "signature" (e.g. "missing:user.email").
110
+ # Stable signatures let scan suppress repeated
111
+ # identical failures while still recording when
112
+ # the failure mode changes meaningfully.
113
+ #
47
114
  # Creates a new event if:
48
- # 1. The status has changed from the last recorded status
49
- # 2. OR the status is in the verify_scan list AND verification fails
115
+ # 1. The status has changed from the last recorded status, OR
116
+ # 2. The status is in the verify_scan list AND the verification signature
117
+ # differs from the last recorded signature.
50
118
  #
51
119
  # @param name [String] the name of the service
52
120
  # @param status [Integer] the HTTP status of the contact
53
121
  # @param response [String] the response object
54
- # @param verifier [Proc] the verification callable which must also respond to to_s
122
+ # @param verifier [#call, #to_s] the verification callable
55
123
  def scan(name, status:, response:, verifier:)
56
124
  service = ParticipantService.find_by!(name:)
57
125
  status = status.to_i # Ensure status is always an integer
58
126
 
127
+ created = nil
59
128
  service.with_lock do
60
129
  last_event = service.events.newest.first
61
130
  last_status = last_event&.status
62
- last_verified = last_event&.verified?
63
131
 
64
- # Calculate current verification result
65
- verified = verifier.call(response)
132
+ signature = signature_for(verifier.call(response))
133
+ verified = (signature == VERIFIED_SIGNATURE)
134
+ last_signature = signature_for_event(last_event)
135
+
136
+ metadata = {verified:, signature:, verification: verifier.to_s}
66
137
 
67
- # Create a new event if:
68
- # 1. Status has changed OR
69
- # 2. Status is in verify_scan_statuses AND verified flag has changed
70
138
  if last_status != status
71
- # Status changed, always create new event
72
- service.events.create!(status:, response:, metadata: {verified:, verification: verifier.to_s})
73
- elsif verify_scan_statuses.include?(status) && last_verified != verified
74
- # Status same but verification result changed for a monitored status
75
- service.events.create!(status:, response:, metadata: {verified:, verification: verifier.to_s})
139
+ created = service.events.create!(status:, response:, metadata:)
140
+ elsif verify_scan_statuses.include?(status) && last_signature != signature
141
+ created = service.events.create!(status:, response:, metadata:)
76
142
  end
77
143
  end
144
+
145
+ instrument(name, created) if created
146
+ created
147
+ end
148
+
149
+ # Normalize a verifier return value into a stable signature string.
150
+ def signature_for(result)
151
+ case result
152
+ when true, :ok then VERIFIED_SIGNATURE
153
+ when false, nil then UNVERIFIED_SIGNATURE
154
+ else result.to_s
155
+ end
156
+ end
157
+
158
+ # Read the signature off a previously stored event, falling back to the
159
+ # legacy verified flag for events created before signatures existed.
160
+ def signature_for_event(event)
161
+ return nil unless event
162
+ stored = event.metadata.to_h["signature"]
163
+ return stored if stored
164
+ event.verified? ? VERIFIED_SIGNATURE : UNVERIFIED_SIGNATURE
78
165
  end
79
166
  alias_method :verify, :scan
80
167
  module_function :verify
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: close_encounters
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.3
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jim Gay
@@ -116,7 +116,9 @@ files:
116
116
  - db/migrate/20240430173725_create_close_encounters_participant_events.rb
117
117
  - db/migrate/20240508190642_add_close_ecounters_indexes.rb
118
118
  - db/migrate/20250225232551_add_metadata_to_close_encounters_participant_events.rb
119
+ - db/migrate/20260724000001_add_service_created_at_index_to_participant_events.rb
119
120
  - lib/close_encounters.rb
121
+ - lib/close_encounters/adapters/net_http.rb
120
122
  - lib/close_encounters/engine.rb
121
123
  - lib/close_encounters/middleware.rb
122
124
  - lib/close_encounters/version.rb
@@ -142,7 +144,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
142
144
  - !ruby/object:Gem::Version
143
145
  version: '0'
144
146
  requirements: []
145
- rubygems_version: 3.6.7
147
+ rubygems_version: 4.0.16
146
148
  specification_version: 4
147
149
  summary: Close Encounters of the Third Party
148
150
  test_files: []