usps-support 0.2.47 → 0.2.48
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/app/controllers/usps/support/health_controller.rb +51 -26
- data/lib/usps/support/health_diagnostics.rb +122 -0
- data/lib/usps/support/version.rb +1 -1
- data/lib/usps/support.rb +1 -0
- metadata +16 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: eccae8acbe04df79930069ce8a386b4f0d8ddb1757731e0b2e75d32367e8c73a
|
|
4
|
+
data.tar.gz: 67bf18bca51ff61bb7d720dcc48afe1e1599e7a4469ac291cab7f6babfd74814
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 0a6f6fb6a20f6537c8c22a30e5002943be03af6ef0ad22c8ab89c53d82b2e70efc9e2b37e497a7fccb379605b64d2dbc8c6d9c854a247f4977766684f4f4c354
|
|
7
|
+
data.tar.gz: 10b196a6d9594e131b005d768e182c9056a3338a6dd5fb4837ec6ac3e4d18a5b225e143af9e806a8faaf22cabf4947ca974d6e2517dcf75275d2ac77815b9c22
|
|
@@ -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
|
|
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
|
-
#
|
|
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'`).
|
|
21
|
-
#
|
|
22
|
-
#
|
|
23
|
-
#
|
|
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
|
-
#
|
|
47
|
-
#
|
|
48
|
-
#
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
58
|
-
# dependency reads as a failed check
|
|
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,122 @@
|
|
|
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
|
+
class << self
|
|
27
|
+
# Enqueue one probe's component results for delivery. Non-blocking; returns nil.
|
|
28
|
+
# components: [{ label:, ok:, latency_ms:, error: }, ...]
|
|
29
|
+
def record(app:, components:)
|
|
30
|
+
return unless enabled?
|
|
31
|
+
|
|
32
|
+
enqueue(app: app, components: components, timestamp_ms: now_ms)
|
|
33
|
+
nil
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def enabled? = !log_group.nil?
|
|
37
|
+
|
|
38
|
+
def log_group = ENV.fetch('HEALTH_DIAGNOSTICS_LOG_GROUP', nil)
|
|
39
|
+
|
|
40
|
+
# Builds the EMF log events for one probe. Pure — the background worker just ships these.
|
|
41
|
+
def emf_events(app:, components:, timestamp_ms:)
|
|
42
|
+
components.map do |component|
|
|
43
|
+
message = {
|
|
44
|
+
'_aws' => {
|
|
45
|
+
'Timestamp' => timestamp_ms,
|
|
46
|
+
'CloudWatchMetrics' => [{
|
|
47
|
+
'Namespace' => NAMESPACE,
|
|
48
|
+
'Dimensions' => [%w[App Component]],
|
|
49
|
+
'Metrics' => [
|
|
50
|
+
{ 'Name' => 'Unhealthy', 'Unit' => 'Count' },
|
|
51
|
+
{ 'Name' => 'LatencyMs', 'Unit' => 'Milliseconds' }
|
|
52
|
+
]
|
|
53
|
+
}]
|
|
54
|
+
},
|
|
55
|
+
'App' => app,
|
|
56
|
+
'Component' => component[:label],
|
|
57
|
+
'Unhealthy' => component[:ok] ? 0 : 1,
|
|
58
|
+
'LatencyMs' => component[:latency_ms],
|
|
59
|
+
'status' => component[:ok] ? 'ok' : 'fail'
|
|
60
|
+
}
|
|
61
|
+
message['error'] = component[:error] if component[:error]
|
|
62
|
+
{ timestamp: timestamp_ms, message: message.to_json }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
private
|
|
67
|
+
|
|
68
|
+
def now_ms = (Time.now.to_f * 1000).to_i
|
|
69
|
+
|
|
70
|
+
# :nocov: -- background delivery: a live thread + the AWS SDK, exercised in prod, not units.
|
|
71
|
+
def enqueue(**item)
|
|
72
|
+
queue.push(item) if queue.size < MAX_QUEUE
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def queue
|
|
76
|
+
return @queue if @queue
|
|
77
|
+
|
|
78
|
+
mutex.synchronize { @queue ||= start_worker }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def mutex = @mutex ||= Mutex.new
|
|
82
|
+
|
|
83
|
+
def start_worker
|
|
84
|
+
queue = Thread::Queue.new
|
|
85
|
+
Thread.new { loop { ship(queue.pop) } }
|
|
86
|
+
queue
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def ship(item)
|
|
90
|
+
ensure_stream
|
|
91
|
+
client.put_log_events(
|
|
92
|
+
log_group_name: log_group,
|
|
93
|
+
log_stream_name: stream_name,
|
|
94
|
+
log_events: emf_events(**item)
|
|
95
|
+
)
|
|
96
|
+
rescue StandardError
|
|
97
|
+
@stream_ensured = false # force a re-create next time; drop this batch
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
def client
|
|
101
|
+
require 'aws-sdk-cloudwatchlogs'
|
|
102
|
+
@client ||= Aws::CloudWatchLogs::Client.new(region: region)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def region = ENV['HEALTH_DIAGNOSTICS_REGION'] || ENV['AWS_REGION'] || 'us-east-1'
|
|
106
|
+
|
|
107
|
+
# One stream per process, so concurrent writers never contend for the same stream.
|
|
108
|
+
def stream_name = @stream_name ||= "#{Socket.gethostname}/#{Process.pid}"
|
|
109
|
+
|
|
110
|
+
def ensure_stream
|
|
111
|
+
return if @stream_ensured
|
|
112
|
+
|
|
113
|
+
client.create_log_stream(log_group_name: log_group, log_stream_name: stream_name)
|
|
114
|
+
@stream_ensured = true
|
|
115
|
+
rescue Aws::CloudWatchLogs::Errors::ResourceAlreadyExistsException
|
|
116
|
+
@stream_ensured = true
|
|
117
|
+
end
|
|
118
|
+
# :nocov:
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
data/lib/usps/support/version.rb
CHANGED
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.
|
|
4
|
+
version: 0.2.48
|
|
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
|