usps-support 0.2.47 → 0.2.49

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: 7d7e4b37785b4d1f70b3721aa387bb0101b42fc54965e540ba3ad3f830490d44
4
- data.tar.gz: 3b804cf40fd405311f71ee157e6eb364019bf464cd49a4ef052e98646b8fba75
3
+ metadata.gz: 5764f3cd03bbd09cf0cc9be4873c9ecba705c6f4a5676058a6577de01999a66f
4
+ data.tar.gz: 50784833a152814d04f12ad8785f2d4c6199b0b88af40ead54c7d0e8c6a0cdf9
5
5
  SHA512:
6
- metadata.gz: 1960575ba4e2b6408a7370867f26257de717f4203051f7f5ebb539d324b309c51e555106351e3e21ac1bf145432d7209d742ed8758a30bd068ca1207ac2fa4cb
7
- data.tar.gz: 3c40989a2707bedf404c19bf0a6f2d3b425f611db081f9f590dbef8e359987927d33a5cf58cde9175b2dffe7a0678b9bc7b080075c4894588ccc401d76801f2a
6
+ metadata.gz: 1ad1f146fd493edbeb08d110abb5de5b825cb5ab93ef494c6f8e12b554615a056940acf8d9ef9a3f39f64fb6ea2adba5ac7f8870282cecd4dd838f2aa01547fd
7
+ data.tar.gz: 6d0a9a31c61db44949f1852853f53e2eb29b6b47cb2aa828398cdc1492f81d29c511d250eae688463a5f018b418a64949bd071bec798db7bd8ff23cb790b98db
@@ -10,17 +10,22 @@ module Usps::Support
10
10
  # the "HEALTHCHECK_OK" prefix, so a quiet app-level failure flips the Statuspage component even
11
11
  # with no organic traffic.
12
12
  #
13
- # Host apps subclass this and override #checks to pick which dependencies to probe, e.g.
13
+ # Host apps subclass this and declare which dependencies to probe with a CHECKS constant — a list
14
+ # of symbols, each naming a #check_<symbol> method:
14
15
  #
15
16
  # class HealthController < Usps::Support::HealthController
16
- # private
17
- # def checks = { database: check_database, redis: check_redis }
17
+ # CHECKS = %i[database redis hq_database imis].freeze
18
18
  # end
19
19
  #
20
- # and route to it (`get 'health' => 'health#show'`). The check_* primitives below each return a
21
- # boolean and never raise, so the action always responds. Apps run only the checks for the
22
- # dependencies they actually configure — admin-console has no HQ connection, so it omits
23
- # check_hq_database; an iMIS-backed app adds check_imis; a custom downstream is check_http(url).
20
+ # and route to it (`get 'health' => 'health#show'`). Each check_* primitive returns a boolean or
21
+ # raises; the base times every check and treats a raise as a failed check, so the action always
22
+ # responds. Apps run only the checks they list — admin-console has no HQ connection, so it omits
23
+ # :hq_database; an iMIS-backed app adds :imis. For a bespoke dependency, define a #check_<name> in
24
+ # the subclass (e.g. wrapping #check_http(url)) and add <name> to CHECKS.
25
+ #
26
+ # Every probe's per-component results (ok, latency, error) are also handed to
27
+ # Usps::Support::HealthDiagnostics, which — when enabled — publishes them to CloudWatch so a past
28
+ # failure can be attributed to a specific component after it has recovered.
24
29
  #
25
30
  # Inherits ActionController::Base directly — NOT the host's ApplicationController — to bypass
26
31
  # Devise auth, Pundit authorization, PaperTrail, and especially `allow_browser`, whose
@@ -29,6 +34,11 @@ module Usps::Support
29
34
  class HealthController < ActionController::Base # rubocop:disable Rails/ApplicationController
30
35
  SENTINEL_PREFIX = 'HEALTHCHECK_OK'
31
36
 
37
+ # Symbols naming the dependency checks to run; each invokes the matching #check_<symbol>.
38
+ # Override per app by defining a CHECKS constant. Default probes the primary database only,
39
+ # which presumably every app needs.
40
+ CHECKS = %i[database].freeze
41
+
32
42
  def show
33
43
  results = checks
34
44
  healthy = results.values.all?
@@ -38,24 +48,47 @@ module Usps::Support
38
48
 
39
49
  response.set_header('Cache-Control', 'no-store')
40
50
  response.set_header('Retry-After', '30') unless healthy
51
+ HealthDiagnostics.record(app: app_name, components: @measurements)
41
52
  render(plain: body, content_type: 'text/plain', status: healthy ? :ok : :service_unavailable)
42
53
  end
43
54
 
44
55
  private
45
56
 
46
- # Dependencies to probe, as { label => boolean }.
47
- #
48
- # Override per app.
49
- #
50
- # Default is the primary database only, which presumably every app needs.
51
- def checks = { database: check_database }
57
+ # Runs each configured check, returning { label => boolean }. Reads the subclass's CHECKS
58
+ # constant, so apps pick checks by listing symbols rather than overriding this method. Each run
59
+ # is timed and its outcome captured in @measurements for HealthDiagnostics.
60
+ def checks
61
+ @measurements = []
62
+ self.class::CHECKS.index_with { |name| measure(name) }
63
+ end
64
+
65
+ # Runs a single named check, timing it and recording its outcome. A raised error reads as a
66
+ # failed check and its class/message is captured, so a dependency that blows up is attributable
67
+ # rather than a 500.
68
+ def measure(name)
69
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
70
+ begin
71
+ ok = send(:"check_#{name}") ? true : false
72
+ error = nil
73
+ rescue StandardError => e
74
+ ok = false
75
+ error = "#{e.class}: #{e.message}"
76
+ end
77
+ @measurements << { label: name.to_s, ok:, latency_ms: elapsed_ms(started), error: }
78
+ ok
79
+ end
80
+
81
+ def elapsed_ms(started) = ((Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000).round(1)
52
82
 
53
83
  # Derives "admin-console" from the host application's module (e.g. AdminConsole), so the sentinel
54
84
  # identifies which app answered without per-app configuration.
55
85
  def app_name = Rails.application.class.module_parent_name.underscore.dasherize
56
86
 
57
- # --- Check primitives. Each returns true/false and swallows its own errors, so a dead
58
- # dependency reads as a failed check rather than a 500. ---
87
+ # --- Check primitives. Each returns true on success and returns false or raises on failure;
88
+ # #measure catches the raise, so a dead dependency reads as a failed check (with its error
89
+ # captured) rather than a 500. Named check_<name>, not <name>?, to drive CHECKS dispatch. ---
90
+
91
+ # rubocop:disable Naming/PredicateMethod
59
92
 
60
93
  # Primary application database (RDS).
61
94
  def check_database = check_connection(::ActiveRecord::Base)
@@ -67,8 +100,6 @@ module Usps::Support
67
100
  def check_redis
68
101
  ::Sidekiq.redis { it.call('PING') }
69
102
  true
70
- rescue StandardError
71
- false
72
103
  end
73
104
 
74
105
  # iMIS API liveness — authenticating proves network + credentials + the token endpoint, against
@@ -76,24 +107,18 @@ module Usps::Support
76
107
  def check_imis
77
108
  ::Usps::Imis::Api.new.auth_token
78
109
  true
79
- rescue StandardError
80
- false
81
110
  end
82
111
 
83
112
  # Generic downstream HTTP liveness for a custom dependency.
84
- def check_http(url)
85
- ::Net::HTTP.get_response(URI(url)).is_a?(::Net::HTTPSuccess)
86
- rescue StandardError
87
- false
88
- end
113
+ def check_http(url) = ::Net::HTTP.get_response(URI(url)).is_a?(::Net::HTTPSuccess)
89
114
 
90
115
  # Checks out a connection from the given model's pool and runs a trivial query — proves the
91
116
  # connection is live, which a boot-time check skips.
92
117
  def check_connection(model)
93
118
  model.connection.execute('SELECT 1')
94
119
  true
95
- rescue StandardError
96
- false
97
120
  end
121
+
122
+ # rubocop:enable Naming/PredicateMethod
98
123
  end
99
124
  end
@@ -0,0 +1,140 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'socket'
5
+
6
+ module Usps
7
+ module Support
8
+ # Fire-and-forget publisher of per-component health-check results to CloudWatch Logs in
9
+ # Embedded Metric Format (EMF). CloudWatch extracts a per-(App, Component) metric from each line
10
+ # while the raw JSON — status, latency, error — stays queryable in Logs Insights. See the
11
+ # infrastructure repo's doc/health_component_metrics.md.
12
+ #
13
+ # Why: /health returns a single 503 when ANY dependency fails, and Route 53's last-failure-reason
14
+ # keeps only the most recent probe, so once the dependency recovers we can no longer tell WHICH
15
+ # component failed (e.g. the Exams/Merit Marks flaps, where hq_database and imis are indistinct
16
+ # from AWS telemetry). This records each component every probe, so a past incident is attributable.
17
+ #
18
+ # Disabled unless HEALTH_DIAGNOSTICS_LOG_GROUP is set, so dev/test and unprovisioned apps are
19
+ # no-ops. Delivery runs on a single background thread off the request path; anything that goes
20
+ # wrong (queue full, SDK missing, API error) drops the record. Diagnostics must never slow or
21
+ # fail /health — that endpoint is exactly what stalls during an incident.
22
+ module HealthDiagnostics
23
+ NAMESPACE = 'USPS/HealthCheck'
24
+ MAX_QUEUE = 1_000
25
+
26
+ # Exponential backoff bounds for the delivery thread, so a persistent failure (e.g. missing
27
+ # logs:CreateLogStream) throttles to one retry per MAX_BACKOFF instead of one per probe.
28
+ BASE_BACKOFF_SECONDS = 1
29
+ MAX_BACKOFF_SECONDS = 60
30
+
31
+ class << self
32
+ # Enqueue one probe's component results for delivery. Non-blocking; returns nil.
33
+ # components: [{ label:, ok:, latency_ms:, error: }, ...]
34
+ def record(app:, components:)
35
+ return unless enabled?
36
+
37
+ enqueue(app: app, components: components, timestamp_ms: now_ms)
38
+ nil
39
+ end
40
+
41
+ def enabled? = !log_group.nil?
42
+
43
+ def log_group = ENV.fetch('HEALTH_DIAGNOSTICS_LOG_GROUP', nil)
44
+
45
+ # Builds the EMF log events for one probe. Pure — the background worker just ships these.
46
+ def emf_events(app:, components:, timestamp_ms:)
47
+ components.map do |component|
48
+ message = {
49
+ '_aws' => {
50
+ 'Timestamp' => timestamp_ms,
51
+ 'CloudWatchMetrics' => [{
52
+ 'Namespace' => NAMESPACE,
53
+ 'Dimensions' => [%w[App Component]],
54
+ 'Metrics' => [
55
+ { 'Name' => 'Unhealthy', 'Unit' => 'Count' },
56
+ { 'Name' => 'LatencyMs', 'Unit' => 'Milliseconds' }
57
+ ]
58
+ }]
59
+ },
60
+ 'App' => app,
61
+ 'Component' => component[:label],
62
+ 'Unhealthy' => component[:ok] ? 0 : 1,
63
+ 'LatencyMs' => component[:latency_ms],
64
+ 'status' => component[:ok] ? 'ok' : 'fail'
65
+ }
66
+ message['error'] = component[:error] if component[:error]
67
+ { timestamp: timestamp_ms, message: message.to_json }
68
+ end
69
+ end
70
+
71
+ private
72
+
73
+ def now_ms = (Time.now.to_f * 1000).to_i
74
+
75
+ # :nocov: -- background delivery: a live thread + the AWS SDK, exercised in prod, not units.
76
+ def enqueue(**item)
77
+ queue.push(item) if queue.size < MAX_QUEUE
78
+ end
79
+
80
+ def queue
81
+ return @queue if @queue
82
+
83
+ mutex.synchronize { @queue ||= start_worker }
84
+ end
85
+
86
+ def mutex = @mutex ||= Mutex.new
87
+
88
+ def start_worker
89
+ queue = Thread::Queue.new
90
+ Thread.new do
91
+ failures = 0
92
+ loop do
93
+ if ship(queue.pop)
94
+ failures = 0
95
+ else
96
+ failures += 1
97
+ sleep([BASE_BACKOFF_SECONDS * (2**(failures - 1)), MAX_BACKOFF_SECONDS].min)
98
+ end
99
+ end
100
+ end
101
+ queue
102
+ end
103
+
104
+ # Ships one probe's events; returns true on success, false (dropping the batch) on any error.
105
+ def ship(item)
106
+ ensure_stream
107
+ client.put_log_events(
108
+ log_group_name: log_group,
109
+ log_stream_name: stream_name,
110
+ log_events: emf_events(**item)
111
+ )
112
+ true
113
+ rescue StandardError
114
+ @stream_ensured = false # force a re-create next time
115
+ false
116
+ end
117
+
118
+ def client
119
+ require 'aws-sdk-cloudwatchlogs'
120
+ @client ||= Aws::CloudWatchLogs::Client.new(region: region)
121
+ end
122
+
123
+ def region = ENV['HEALTH_DIAGNOSTICS_REGION'] || ENV['AWS_REGION'] || 'us-east-1'
124
+
125
+ # One stream per process, so concurrent writers never contend for the same stream.
126
+ def stream_name = @stream_name ||= "#{Socket.gethostname}/#{Process.pid}"
127
+
128
+ def ensure_stream
129
+ return if @stream_ensured
130
+
131
+ client.create_log_stream(log_group_name: log_group, log_stream_name: stream_name)
132
+ @stream_ensured = true
133
+ rescue Aws::CloudWatchLogs::Errors::ResourceAlreadyExistsException
134
+ @stream_ensured = true
135
+ end
136
+ # :nocov:
137
+ end
138
+ end
139
+ end
140
+ end
@@ -2,6 +2,6 @@
2
2
 
3
3
  module Usps
4
4
  module Support
5
- VERSION = '0.2.47'
5
+ VERSION = '0.2.49'
6
6
  end
7
7
  end
data/lib/usps/support.rb CHANGED
@@ -17,6 +17,7 @@ end
17
17
  require_relative 'support/helpers'
18
18
  require_relative 'support/models'
19
19
  require_relative 'support/lib'
20
+ require_relative 'support/health_diagnostics'
20
21
 
21
22
  # The Engine require lives in `usps/all`, not here. This file can
22
23
  # legitimately be required before Rails is loaded (e.g. from .simplecov to pull
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: usps-support
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.2.47
4
+ version: 0.2.49
5
5
  platform: ruby
6
6
  authors:
7
7
  - Julian Fiander
@@ -23,6 +23,20 @@ dependencies:
23
23
  - - ">="
24
24
  - !ruby/object:Gem::Version
25
25
  version: '0'
26
+ - !ruby/object:Gem::Dependency
27
+ name: aws-sdk-cloudwatchlogs
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '1'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '1'
26
40
  - !ruby/object:Gem::Dependency
27
41
  name: usps-imis-api
28
42
  requirement: !ruby/object:Gem::Requirement
@@ -83,6 +97,7 @@ files:
83
97
  - lib/usps/support/db/members_schema.rb
84
98
  - lib/usps/support/db/websites_schema.rb
85
99
  - lib/usps/support/engine.rb
100
+ - lib/usps/support/health_diagnostics.rb
86
101
  - lib/usps/support/helpers.rb
87
102
  - lib/usps/support/helpers/badges_helper.rb
88
103
  - lib/usps/support/helpers/flags_helper.rb