close_encounters 0.2.4 → 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: be9653e9826ea4a3ba675cc0a35c68eb9ff77404e8ae5569a2834682b4800a61
4
- data.tar.gz: 8d61d965ce13c0e67a960bc90a4ace544ad215aa6d58f7c28c04a140258f6ab9
3
+ metadata.gz: 153aff29d52784d224dc23d3cb5606fe7a1478c2522bff75dc80678d236f3a47
4
+ data.tar.gz: c2de654d365fe8ec5330b2254b45a56029e75895a808c90fc261d9e0aae333b2
5
5
  SHA512:
6
- metadata.gz: 8f279069dfbdaa9aff1e4802d15950dbc03f6ad411bcb174f4935efb4a2bbe245cf920c6cd00a522365c8a45c56202ae5a06568a67ba6fdc4965ed101586bdba
7
- data.tar.gz: 207655591aedb84b5cbf5b0e5a5c78e4d7239f3dd00d57978a46c227b3aca59cb52c89c99137adb46e0d7e50412785063b86579edc252b6428ded9c81e21333e
6
+ metadata.gz: 2e1105a5a609dd0a34a0bd1d14bae3f043ab6757e6fe4d0634ba03e3eb101238007da02aea099c892668bc0c5f93614074fbd1b82597730a1534f4595b00b59a
7
+ data.tar.gz: 775ff7eb0f7d5d61523e1b47eeb3da245ef4c27b5b9ed740f31d22d15d6550dd81caa2fd333a9d4a6e1aebef39cb2e88cc932206143532681de8081a8421af32
data/CHANGELOG.md CHANGED
@@ -5,10 +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.4] - 2026-04-06
8
+ ## [0.3.0] - 2026-07-24
9
+
10
+ ### Added
11
+
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)
14
+
15
+ ### Changed
9
16
 
10
- ## [0.2.3] - 2025-09-08
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)
11
24
 
12
25
  ### Fixed
13
26
 
14
- - Fix duplicate event creation in `scan` method when status and verification state are unchanged
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
@@ -71,5 +107,7 @@ $ gem install close_encounters
71
107
 
72
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.
73
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
+
74
112
  ## License
75
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,7 @@ 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
23
24
  task.push_finalize = :branch
24
25
  end
25
26
 
@@ -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.4"
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,14 +43,59 @@ 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
+
44
99
  VERIFIED_SIGNATURE = "ok"
45
100
  UNVERIFIED_SIGNATURE = "unverified"
46
101
 
@@ -69,6 +124,7 @@ module CloseEncounters
69
124
  service = ParticipantService.find_by!(name:)
70
125
  status = status.to_i # Ensure status is always an integer
71
126
 
127
+ created = nil
72
128
  service.with_lock do
73
129
  last_event = service.events.newest.first
74
130
  last_status = last_event&.status
@@ -80,11 +136,14 @@ module CloseEncounters
80
136
  metadata = {verified:, signature:, verification: verifier.to_s}
81
137
 
82
138
  if last_status != status
83
- service.events.create!(status:, response:, metadata:)
139
+ created = service.events.create!(status:, response:, metadata:)
84
140
  elsif verify_scan_statuses.include?(status) && last_signature != signature
85
- service.events.create!(status:, response:, metadata:)
141
+ created = service.events.create!(status:, response:, metadata:)
86
142
  end
87
143
  end
144
+
145
+ instrument(name, created) if created
146
+ created
88
147
  end
89
148
 
90
149
  # Normalize a verifier return value into a stable signature string.
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.4
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.7.2
147
+ rubygems_version: 4.0.16
146
148
  specification_version: 4
147
149
  summary: Close Encounters of the Third Party
148
150
  test_files: []