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 +4 -4
- data/CHANGELOG.md +19 -3
- data/README.md +48 -10
- data/Rakefile +1 -0
- data/app/models/close_encounters/participant_event.rb +3 -1
- data/db/migrate/20260724000001_add_service_created_at_index_to_participant_events.rb +18 -0
- data/lib/close_encounters/adapters/net_http.rb +14 -0
- data/lib/close_encounters/engine.rb +4 -0
- data/lib/close_encounters/middleware.rb +17 -4
- data/lib/close_encounters/version.rb +1 -1
- data/lib/close_encounters.rb +62 -3
- metadata +4 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 153aff29d52784d224dc23d3cb5606fe7a1478c2522bff75dc80678d236f3a47
|
|
4
|
+
data.tar.gz: c2de654d365fe8ec5330b2254b45a56029e75895a808c90fc261d9e0aae333b2
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
|
|
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
|
-
-
|
|
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
|
|
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
|
-
#
|
|
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
|
-
###
|
|
30
|
+
### Recording a response from any HTTP client
|
|
31
31
|
|
|
32
|
-
|
|
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
|
-
|
|
37
|
+
response = SomeThirdPartyService.call # a Net::HTTP response
|
|
38
|
+
CloseEncounters.record("SomeThirdPartyService", response, adapter: CloseEncounters::Adapters::NetHTTP)
|
|
36
39
|
```
|
|
37
40
|
|
|
38
|
-
|
|
39
|
-
`connection_info` in the ParticipantService records.
|
|
41
|
+
Pass a `verifier` to record a verified scan instead of a plain contact:
|
|
40
42
|
|
|
41
|
-
|
|
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
|
-
|
|
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
|
@@ -5,7 +5,9 @@ module CloseEncounters
|
|
|
5
5
|
class_name: "CloseEncounters::ParticipantService",
|
|
6
6
|
foreign_key: "close_encounters_participant_service_id"
|
|
7
7
|
|
|
8
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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
|
data/lib/close_encounters.rb
CHANGED
|
@@ -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.
|
|
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:
|
|
147
|
+
rubygems_version: 4.0.16
|
|
146
148
|
specification_version: 4
|
|
147
149
|
summary: Close Encounters of the Third Party
|
|
148
150
|
test_files: []
|