deployangel 0.1.9 → 0.1.11

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: e2bc6936b563039a1c2caa91f3a8125f0c32582366505ab35dfabf2ec90af3c5
4
- data.tar.gz: 22c805b0adef58922b3513afc19492433421358be2e75fd12513e650a7e57c9c
3
+ metadata.gz: 832e7ada784cf0e86d34f802ef299dfd9114b844f542444a69331b7d622e3d35
4
+ data.tar.gz: 31c8f9e2efc46a96325e7813c8b5902c23cbf758c55dccdb3536048af881f7a7
5
5
  SHA512:
6
- metadata.gz: 361b042f1d885fc63442dff6d4f4c406b36fd14ea5c226cf04a5bea2f7537770c6b4f4ad169331b95e6f3bdacf37169f90ac70fc6567006203f4884fe021236a
7
- data.tar.gz: 328b709591c7c5441390fb74dc535d0a515061fd64161bbe555ffe9f4f8602bf32f10676b6119458e44837ee539e96e5e2f4d72b97cafc05e203d3ef2eb77e7b
6
+ metadata.gz: a0e4683b471211a023e925fa4cbacdd57f908c0a1c1ee18af950490b291b47c9cfe9a33bb4d3a5e376e5992f8d478919167d0d32320448c5c3c362a9873e6afa
7
+ data.tar.gz: 2cc10162cd04a158808c71eb51fb30b36716a3d9ca8e70c231d35289de887810194bf0b172481fd0f68d5ad025e36e3b8dda6b34335b2701b7b9b6980e5ea1d0
data/CHANGELOG.md CHANGED
@@ -1,5 +1,29 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.11 (2026-10-05)
4
+
5
+ - Heroku: the commit comes from `HEROKU_BUILD_COMMIT`
6
+ (`heroku labs:enable runtime-dyno-build-metadata`) when it's set, and from
7
+ `HEROKU_SLUG_COMMIT`, which Heroku has deprecated, otherwise.
8
+ - In GitHub Actions, `deployangel verify` and `status` also add the verdict,
9
+ its failing and warning findings, new exceptions, and a dashboard link to
10
+ the job's summary page (`GITHUB_STEP_SUMMARY`). Output and exit codes are
11
+ unchanged.
12
+
13
+ ## 0.1.10 (2026-10-04)
14
+
15
+ - `config.exception_messages = false` (or `DEPLOYANGEL_EXCEPTION_MESSAGES=false`)
16
+ sends exceptions with their class, fingerprint, and application frames, and
17
+ no message. Grouping and verdicts work the same.
18
+ - The request's host is replaced with `<host>` in exception messages, and with
19
+ ros-apartment, so are the tenants the request or job switched to
20
+ (`<tenant>`), including one that turned out not to exist. The default tenant
21
+ is left alone.
22
+ - ECS, including Fargate: the release comes from the container's image, read
23
+ once at boot from `ECS_CONTAINER_METADATA_URI_V4`. A tag that's a commit is
24
+ the commit, another tag is the version, and a moving tag such as `latest` uses
25
+ the image digest. `DEPLOYANGEL_REVISION` and a `REVISION` file still win.
26
+
3
27
  ## 0.1.9 (2026-10-04)
4
28
 
5
29
  - A 4xx response to a request no route matched, such as a bot probing
data/README.md CHANGED
@@ -24,6 +24,8 @@ The agent must know which release it is running. It finds it in this order:
24
24
 
25
25
  1. `DEPLOYANGEL_REVISION` (the commit SHA) and `DEPLOYANGEL_RELEASE_VERSION`, if you set them
26
26
  2. Heroku dyno metadata. Enable it with `heroku labs:enable runtime-dyno-metadata`
27
+ and `heroku labs:enable runtime-dyno-build-metadata` (for `HEROKU_BUILD_COMMIT`,
28
+ which replaces the deprecated `HEROKU_SLUG_COMMIT`); both take effect on the next deploy
27
29
  3. Kamal: `KAMAL_VERSION`, which Kamal sets in every container
28
30
  4. Render: `RENDER_GIT_COMMIT`
29
31
  5. Fly.io: the deploy's image tag, from `FLY_IMAGE_REF`. Fly.io sets no commit, so
@@ -34,9 +36,14 @@ The agent must know which release it is running. It finds it in this order:
34
36
  didn't come from GitHub
35
37
  7. Coolify: `SOURCE_COMMIT`. Dokku: `GIT_REV`
36
38
  8. a `REVISION` file in the app root, which Capistrano writes and any build step can
39
+ 9. ECS, including Fargate: the container's image, from the metadata endpoint ECS
40
+ provides (one local request at boot). The image tag is the release when it's a
41
+ commit or a version like `v1.4.2`; with a moving tag like `latest`, the image
42
+ digest is
37
43
 
38
- For other Docker deploys (compose, Swarm, ECS, Kubernetes), bake the commit into
39
- the image, since `.dockerignore` usually leaves `.git` out:
44
+ For other Docker deploys (compose, Swarm, Kubernetes), and on ECS to see each
45
+ release by its commit, bake the commit into the image, since `.dockerignore`
46
+ usually leaves `.git` out:
40
47
 
41
48
  ```dockerfile
42
49
  ARG GIT_SHA
@@ -63,9 +70,13 @@ says so.
63
70
  middleware. Failures that `retry_on` or `discard_on` handle still count.
64
71
  - Release identity, runtime versions, and a per-process instance ID.
65
72
 
66
- - Exceptions: a stable fingerprint, the exception class, a sanitized message
67
- (numbers, IDs, emails, and quoted values removed), and application frames
68
- only.
73
+ - Exceptions: a stable fingerprint, the exception class, the first line of the
74
+ message, and application frames only. In the message, numbers, IDs, emails,
75
+ UUIDs, long hex strings, and quoted values are replaced with placeholders, and
76
+ so are the request's host and, with ros-apartment, the tenants the request or
77
+ job switched to. Other words are kept: `Payment failed for Jane Doe` is sent
78
+ as it is. If your app's messages might hold personal or health data, [turn
79
+ messages off](#exception-messages).
69
80
  - Once per process: the route table, job classes, recurring schedules
70
81
  declared for Solid Queue, sidekiq-cron, or sidekiq-scheduler, critical
71
82
  flows, and file digests (relative paths and hashes, never file contents) so
@@ -140,6 +151,29 @@ DeployAngel.configure do |config|
140
151
  end
141
152
  ```
142
153
 
154
+ `DEPLOYANGEL_ENABLED=true|false` forces reporting on or off in any environment.
155
+ `DEPLOYANGEL_URL` overrides the API endpoint (default `https://api.deployangel.com`).
156
+
157
+ ### Exception messages
158
+
159
+ To send exceptions without any message, only their class, fingerprint, and
160
+ application frames:
161
+
162
+ ```ruby
163
+ DeployAngel.configure do |config|
164
+ config.exception_messages = false # or DEPLOYANGEL_EXCEPTION_MESSAGES=false
165
+ end
166
+ ```
167
+
168
+ Grouping, new-exception detection, and verdicts work the same, since the
169
+ fingerprint never uses the message. You lose the message text in the dashboard,
170
+ notifications, and AI investigation. With messages off, what leaves your app is
171
+ route patterns, counts, timings, job class names, exception classes, and
172
+ application file paths and method names, plus the route table, job classes,
173
+ schedules, and file digests described above.
174
+
175
+ ### Recurring jobs
176
+
143
177
  DeployAngel expects declared recurring jobs on schedule. It reads Solid Queue's
144
178
  `config/recurring.yml`, sidekiq-scheduler's section of Sidekiq's config, and
145
179
  sidekiq-cron's schedule file (`config/schedule.yml` unless sidekiq-cron is
@@ -156,9 +190,6 @@ end
156
190
  Jobs that exist only in Redis, such as ones created in code or in the
157
191
  Sidekiq web UI, aren't read.
158
192
 
159
- `DEPLOYANGEL_ENABLED=true|false` forces reporting on or off in any environment.
160
- `DEPLOYANGEL_URL` overrides the API endpoint (default `https://api.deployangel.com`).
161
-
162
193
  ## Checkpoints
163
194
 
164
195
  Errors and latency don't catch work that silently stops happening. Count the
@@ -214,6 +245,9 @@ to the run. Explicit options still win.
214
245
  DEPLOYANGEL_API_TOKEN: ${{ secrets.DEPLOYANGEL_API_TOKEN }}
215
246
  ```
216
247
 
248
+ In GitHub Actions, `verify` also puts the verdict, its failing findings, and
249
+ new exceptions on the job's summary page.
250
+
217
251
  ### When a release never reports
218
252
 
219
253
  A registered release must start reporting within 15 minutes of being
@@ -91,7 +91,9 @@ module DeployAngel
91
91
 
92
92
  exception.instance_variable_set(SEEN, true) unless exception.frozen?
93
93
  start_reporter
94
- @aggregator.record_exception(Fingerprint.for(exception, root: @root), source: source, handled: handled,
94
+ messages = config.exception_messages
95
+ details = Fingerprint.for(exception, root: @root, message: messages, redactions: messages ? Redaction.current : [])
96
+ @aggregator.record_exception(details, source: source, handled: handled,
95
97
  backtrace: Fingerprint.backtrace(exception, root: @root))
96
98
  rescue StandardError => e
97
99
  warn_once(:record_exception, "DeployAngel failed to record an exception: #{e.class}: #{e.message}")
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeployAngel
4
+ # Notes each tenant ros-apartment switches to, before switching, so even a
5
+ # tenant that turns out not to exist ("Could not find schema acme") is
6
+ # replaced in exception messages (Redaction). The default tenant, usually
7
+ # "public", is left alone.
8
+ module Apartment
9
+ module Switch
10
+ def switch!(tenant = nil)
11
+ begin
12
+ DeployAngel::Redaction.note_tenant(tenant) unless tenant.nil? || tenant.to_s == default_tenant.to_s
13
+ rescue StandardError
14
+ nil
15
+ end
16
+ super
17
+ end
18
+ end
19
+
20
+ def self.install
21
+ return unless defined?(::Apartment::Tenant)
22
+
23
+ require "apartment/adapters/abstract_adapter" unless defined?(::Apartment::Adapters::AbstractAdapter)
24
+ adapter = ::Apartment::Adapters::AbstractAdapter
25
+ adapter.prepend(Switch) unless adapter < Switch
26
+ rescue LoadError, StandardError
27
+ nil
28
+ end
29
+ end
30
+ end
@@ -18,45 +18,113 @@ module DeployAngel
18
18
  module_function
19
19
 
20
20
  def verification(document)
21
- v = document["verification"] || {}
22
- clearance = document["clearance"] || {}
23
- lines = [ clearance["statement"] || document["summary"] ]
24
- lines << [ "State: #{v["state"]}", ("Verdict: #{v["verdict"]}" if v["verdict"]),
25
- ("Confidence: #{v["confidence"]}" if v["confidence"]),
26
- ("Coverage: #{(v["coverage"] * 100).round}%" if v["coverage"]) ].compact.join(" · ")
27
- if (check = v["initial_check"]) && v["verdict"].nil?
28
- lines << "Initial check: #{check["result"].tr("_", " ")}. Not cleared yet."
21
+ lines = [ statement(document), status_line(document), *notes(document) ]
22
+ problems = problems(document)
23
+ if problems.any?
24
+ lines << "Findings:"
25
+ problems.each { |f| lines << " #{f["status"].ljust(8)} #{finding_line(f)}" }
29
26
  end
30
- lines << "Expected clearance: #{v["expected_clearance_at"]}" if v["expected_clearance_at"] && v["verdict"].nil?
31
- if (source = document.dig("deployment", "promoted_from"))
32
- outcome = { "verified" => "cleared", "failed" => "failed", "inconclusive" => "not cleared" }.fetch(source["verdict"], "still verifying")
33
- lines << "Promoted from #{source["environment"]} #{source["version"] || source["commit"].to_s[0, 7]} (#{outcome})"
27
+ if (gathering = gathering_line(document))
28
+ lines << "#{gathering} (--format=json for details)"
34
29
  end
30
+ sections(document).each { |title, items| section(lines, title, items) }
31
+ lines << document["dashboard_url"] if document["dashboard_url"]
32
+ lines.compact.join("\n")
33
+ end
35
34
 
36
- findings = Array(document["findings"])
37
- problems = findings.select { |f| %w[failing warning].include?(f["status"]) }
35
+ # The same document as GitHub-flavored Markdown, for a CI job's
36
+ # summary page.
37
+ def markdown(document)
38
+ lines = [ "### DeployAngel: #{release_name(document)} #{outcome(document)}", "", statement(document).to_s,
39
+ "", status_line(document), *notes(document).map { |note| "\n#{note}" } ]
40
+ problems = problems(document)
38
41
  if problems.any?
39
- lines << "Findings:"
40
- problems.each { |f| lines << " #{f["status"].ljust(8)} #{finding_line(f)}" }
42
+ lines.push("", "| Status | Finding |", "| --- | --- |")
43
+ problems.each { |f| lines << "| #{f["status"]} | #{cell(finding_line(f))} |" }
41
44
  end
42
- gathering = findings.count { |f| f["status"] == "insufficient_data" }
43
- lines << "#{gathering} signal#{"s" unless gathering == 1} still gathering data (--format=json for details)" if gathering.positive?
44
- section(lines, "New exceptions", Array(document["exceptions"])) do |e|
45
- "#{e["exception_class"]} in #{e["top_frame"]} (#{e["count"]}x)#{" #{e["sources"].keys.join(", ")}" if e["sources"].is_a?(Hash) && e["sources"].any?}"
45
+ if (gathering = gathering_line(document))
46
+ lines.push("", gathering)
46
47
  end
47
- unless v["verdict"] == "failed"
48
- section(lines, "Missing evidence", Array(clearance["missing_evidence"])) { |item| item }
48
+ sections(document).each do |title, items|
49
+ lines.push("", "**#{title}**", "")
50
+ items.each { |item| lines << "- #{item}" }
49
51
  end
50
- section(lines, "Not observable", Array(clearance["not_observable"])) { |item| "#{item["label"]} (#{item["reason"]})" }
51
- section(lines, "Still watching", Array(clearance["still_watching"])) { |e| "#{e["job_class"]} (expected by #{e["expected_by"]})" }
52
- section(lines, "Critical flows", Array(document["critical_flows"])) do |flow|
53
- "#{flow["name"]}: #{flow["status"].tr("_", " ")}#{" (changed in this release)" if flow["changed_in_release"]}"
52
+ lines.push("", "[Open in DeployAngel](#{document["dashboard_url"]})") if document["dashboard_url"]
53
+ lines.compact.join("\n") + "\n"
54
+ end
55
+
56
+ def statement(document)
57
+ (document["clearance"] || {})["statement"] || document["summary"]
58
+ end
59
+
60
+ def status_line(document)
61
+ v = document["verification"] || {}
62
+ [ "State: #{v["state"]}", ("Verdict: #{v["verdict"]}" if v["verdict"]),
63
+ ("Confidence: #{v["confidence"]}" if v["confidence"]),
64
+ ("Coverage: #{(v["coverage"] * 100).round}%" if v["coverage"]) ].compact.join(" · ")
65
+ end
66
+
67
+ def notes(document)
68
+ v = document["verification"] || {}
69
+ notes = []
70
+ if (check = v["initial_check"]) && v["verdict"].nil?
71
+ notes << "Initial check: #{check["result"].tr("_", " ")}. Not cleared yet."
54
72
  end
73
+ notes << "Expected clearance: #{v["expected_clearance_at"]}" if v["expected_clearance_at"] && v["verdict"].nil?
74
+ if (source = document.dig("deployment", "promoted_from"))
75
+ outcome = { "verified" => "cleared", "failed" => "failed", "inconclusive" => "not cleared" }.fetch(source["verdict"], "still verifying")
76
+ notes << "Promoted from #{source["environment"]} #{source["version"] || source["commit"].to_s[0, 7]} (#{outcome})"
77
+ end
78
+ notes
79
+ end
80
+
81
+ def problems(document)
82
+ Array(document["findings"]).select { |f| %w[failing warning].include?(f["status"]) }
83
+ end
84
+
85
+ def gathering_line(document)
86
+ gathering = Array(document["findings"]).count { |f| f["status"] == "insufficient_data" }
87
+ "#{gathering} signal#{"s" unless gathering == 1} still gathering data" if gathering.positive?
88
+ end
89
+
90
+ # Titled lists that follow the findings, each item already a line.
91
+ def sections(document)
92
+ v = document["verification"] || {}
93
+ clearance = document["clearance"] || {}
55
94
  changed = Array(document["rare_items_pending"]).select { |item| item["changed_in_release"] == "changed" }
56
- section(lines, "Changed but not yet exercised", changed) { |item| item["key"] }
57
- section(lines, "Late regressions", Array(document["late_regressions"])) { |late| late["summary"] }
58
- lines << document["dashboard_url"] if document["dashboard_url"]
59
- lines.compact.join("\n")
95
+ [
96
+ [ "New exceptions", Array(document["exceptions"]).map do |e|
97
+ "#{e["exception_class"]} in #{e["top_frame"]} (#{e["count"]}x)#{" #{e["sources"].keys.join(", ")}" if e["sources"].is_a?(Hash) && e["sources"].any?}"
98
+ end ],
99
+ [ "Missing evidence", v["verdict"] == "failed" ? [] : Array(clearance["missing_evidence"]) ],
100
+ [ "Not observable", Array(clearance["not_observable"]).map { |item| "#{item["label"]} (#{item["reason"]})" } ],
101
+ [ "Still watching", Array(clearance["still_watching"]).map { |e| "#{e["job_class"]} (expected by #{e["expected_by"]})" } ],
102
+ [ "Critical flows", Array(document["critical_flows"]).map do |flow|
103
+ "#{flow["name"]}: #{flow["status"].tr("_", " ")}#{" (changed in this release)" if flow["changed_in_release"]}"
104
+ end ],
105
+ [ "Changed but not yet exercised", changed.map { |item| item["key"] } ],
106
+ [ "Late regressions", Array(document["late_regressions"]).map { |late| late["summary"] } ]
107
+ ].reject { |_, items| items.empty? }
108
+ end
109
+
110
+ def release_name(document)
111
+ deployment = document["deployment"] || {}
112
+ deployment["version"] || deployment["commit"].to_s[0, 7]
113
+ end
114
+
115
+ def outcome(document)
116
+ v = document["verification"] || {}
117
+ case v["verdict"]
118
+ when "verified" then "cleared"
119
+ when "failed" then "failed"
120
+ when "inconclusive" then "not cleared"
121
+ else
122
+ case v.dig("initial_check", "result")
123
+ when "warnings" then "has warnings, not cleared yet"
124
+ when nil then "is still being verified"
125
+ else "has no problems so far, not cleared yet"
126
+ end
127
+ end
60
128
  end
61
129
 
62
130
  def exception(details)
@@ -82,10 +150,12 @@ module DeployAngel
82
150
  end
83
151
 
84
152
  def section(lines, title, items)
85
- return if items.empty?
86
-
87
153
  lines << "#{title}:"
88
- items.each { |item| lines << " #{yield(item)}" }
154
+ items.each { |item| lines << " #{item}" }
155
+ end
156
+
157
+ def cell(text)
158
+ text.gsub("|", "\\|")
89
159
  end
90
160
 
91
161
  def value(finding, raw)
@@ -139,11 +139,13 @@ module DeployAngel
139
139
  outcome = waiter.wait(target, until_mode: options[:until_mode], timeout: options[:timeout], wait: options[:wait])
140
140
  if outcome.not_found
141
141
  @stderr.puts("deployangel: no deployment found for #{target.values.first}")
142
+ step_summary("### DeployAngel: no deployment found for #{target.values.first}\n")
142
143
  return outcome.exit_code
143
144
  end
144
145
 
145
146
  document = options[:all_findings] ? client.verification(outcome.document.dig("deployment", "id"), all_findings: true) : outcome.document
146
147
  output(document, options[:format]) { Formatter.verification(document) }
148
+ step_summary(Formatter.markdown(document))
147
149
  @stderr.puts("deployangel: timed out; verification is still in progress") if outcome.timed_out
148
150
  outcome.exit_code
149
151
  end
@@ -228,6 +230,16 @@ module DeployAngel
228
230
  @stdout.puts(format == "json" ? JSON.pretty_generate(document) : yield)
229
231
  end
230
232
 
233
+ # In GitHub Actions, the verdict also goes on the run's summary page.
234
+ def step_summary(markdown)
235
+ path = @env["GITHUB_STEP_SUMMARY"].to_s
236
+ return if path.empty?
237
+
238
+ File.open(path, "a") { |file| file.puts(markdown) }
239
+ rescue SystemCallError => e
240
+ @stderr.puts("deployangel: couldn't write the job summary (#{e.message})")
241
+ end
242
+
231
243
  def parse_duration(value)
232
244
  match = value.to_s.match(/\A(\d+)(s|m|h)?\z/) or raise OptionParser::InvalidArgument, "--timeout=#{value}"
233
245
  match[1].to_i * { nil => 1, "s" => 1, "m" => 60, "h" => 3600 }.fetch(match[2])
@@ -11,7 +11,7 @@ module DeployAngel
11
11
 
12
12
  attr_accessor :token, :endpoint, :enabled, :environments, :release_version, :revision,
13
13
  :flush_interval, :open_timeout, :read_timeout, :max_queued_payloads, :max_routes, :logger,
14
- :file_digests, :critical_flows, :ignored_routes, :sidekiq_cron_schedule_file
14
+ :file_digests, :critical_flows, :ignored_routes, :sidekiq_cron_schedule_file, :exception_messages
15
15
 
16
16
  def initialize(env = ENV)
17
17
  @token = env["DEPLOYANGEL_TOKEN"]
@@ -27,6 +27,9 @@ module DeployAngel
27
27
  @max_routes = 100
28
28
  @logger = Logger.new($stderr, level: Logger::WARN, progname: "deployangel")
29
29
  @file_digests = parse_boolean(env["DEPLOYANGEL_FILE_DIGESTS"]) != false
30
+ # Off, exceptions are sent with their class and frames only, never a
31
+ # message, for apps whose messages may hold personal or health data.
32
+ @exception_messages = parse_boolean(env["DEPLOYANGEL_EXCEPTION_MESSAGES"]) != false
30
33
  @critical_flows = {}
31
34
  # Route keys as the dashboard shows them, such as "GET /healthz".
32
35
  @ignored_routes = []
@@ -21,13 +21,15 @@ module DeployAngel
21
21
 
22
22
  module_function
23
23
 
24
- def for(exception, root:)
24
+ # message: false leaves the message out entirely. redactions are
25
+ # [value, placeholder] pairs replaced first (Redaction).
26
+ def for(exception, root:, message: true, redactions: [])
25
27
  frame = top_frame(exception, root: root)
26
28
  {
27
29
  "fingerprint" => Digest::SHA256.hexdigest([ "v#{VERSION}", exception.class.name, frame ].join("|"))[0, 32],
28
30
  "fingerprint_version" => VERSION,
29
31
  "exception_class" => exception.class.name,
30
- "message" => normalize_message(exception.message),
32
+ "message" => (normalize_message(exception.message, redactions) if message),
31
33
  "top_frame" => frame,
32
34
  "app_frame" => app_frame?(frame)
33
35
  }
@@ -50,8 +52,13 @@ module DeployAngel
50
52
  .map { |path, label| "#{normalize_path(path, root)}##{label}" }
51
53
  end
52
54
 
53
- def normalize_message(message)
55
+ # Only quoted values, numbers, emails, UUIDs, and long hex are replaced;
56
+ # unquoted words, such as a name in a message the app builds, are kept.
57
+ def normalize_message(message, redactions = [])
54
58
  text = message.to_s.lines.first.to_s.strip
59
+ redactions.each do |value, placeholder|
60
+ text = text.gsub(/(?<![[:alnum:]])#{Regexp.escape(value)}(?![[:alnum:]])/i, placeholder)
61
+ end
55
62
  MESSAGE_PLACEHOLDERS.each { |pattern, placeholder| text = text.gsub(pattern, placeholder) }
56
63
  text[0, MAX_MESSAGE]
57
64
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeployAngel
4
+ # Values that name a customer of the app rather than its code, replaced in
5
+ # exception messages before they're cleaned: the request's host and, with
6
+ # ros-apartment, the tenants this thread last switched to. Kept per thread
7
+ # (per fiber), where the request or job that raised ran.
8
+ module Redaction
9
+ HOST = :deployangel_request_host
10
+ TENANTS = :deployangel_tenants
11
+ MAX_TENANTS = 5
12
+ # Shorter names would replace ordinary words.
13
+ MIN_LENGTH = 3
14
+
15
+ module_function
16
+
17
+ def request_host=(host)
18
+ Thread.current[HOST] = host
19
+ end
20
+
21
+ def note_tenant(tenant)
22
+ name = tenant.to_s.strip
23
+ return if name.length < MIN_LENGTH
24
+
25
+ tenants = (Thread.current[TENANTS] ||= [])
26
+ tenants.delete(name)
27
+ tenants.unshift(name)
28
+ tenants.pop while tenants.size > MAX_TENANTS
29
+ end
30
+
31
+ # [value, placeholder] pairs, longest first, so a host is replaced before
32
+ # a tenant name inside it.
33
+ def current
34
+ host = Thread.current[HOST].to_s.sub(/:\d+\z/, "")
35
+ pairs = Array(Thread.current[TENANTS]).map { |tenant| [ tenant, "<tenant>" ] }
36
+ pairs << [ host, "<host>" ] if host.length >= MIN_LENGTH
37
+ pairs.sort_by { |value, _| -value.length }
38
+ end
39
+ end
40
+ end
@@ -1,19 +1,26 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "json"
4
+ require "net/http"
5
+ require "uri"
6
+
3
7
  module DeployAngel
4
8
  # Which release this process is running, resolved once at boot so that
5
9
  # telemetry can be attributed to a deployment.
6
10
  class Release < Struct.new(:version, :commit, :source)
7
11
  COMMIT_FORMAT = /\A[0-9a-f]{7,40}\z/
8
12
 
13
+ # Tags that move from build to build, so they can't identify a release.
14
+ MOVING_TAGS = %w[latest main master production prod staging stable release].freeze
15
+
9
16
  # Order: explicit configuration, Heroku dyno metadata, the hosting
10
17
  # platform's own variables (Kamal, Render, Fly.io, Railway, Coolify, and
11
- # Dokku's GIT_REV), then a REVISION file.
12
- def self.resolve(config:, env: ENV, root: nil)
18
+ # Dokku's GIT_REV), a REVISION file, then ECS container metadata.
19
+ def self.resolve(config:, env: ENV, root: nil, http: method(:fetch_metadata))
13
20
  if present?(config.release_version) || present?(config.revision)
14
21
  build(config.release_version, config.revision, "config")
15
- elsif present?(env["HEROKU_RELEASE_VERSION"]) || present?(env["HEROKU_SLUG_COMMIT"])
16
- build(env["HEROKU_RELEASE_VERSION"], env["HEROKU_SLUG_COMMIT"], "heroku_dyno_metadata")
22
+ elsif present?(env["HEROKU_RELEASE_VERSION"]) || present?(heroku_commit(env))
23
+ build(env["HEROKU_RELEASE_VERSION"], heroku_commit(env), "heroku_dyno_metadata")
17
24
  elsif present?(env["KAMAL_VERSION"])
18
25
  kamal(env["KAMAL_VERSION"])
19
26
  elsif present?(env["RENDER_GIT_COMMIT"])
@@ -29,6 +36,8 @@ module DeployAngel
29
36
  build(nil, env["GIT_REV"], "git_rev")
30
37
  elsif root && File.file?(revision_path = File.join(root, "REVISION"))
31
38
  build(nil, File.read(revision_path, 100), "revision_file")
39
+ elsif present?(env["ECS_CONTAINER_METADATA_URI_V4"]) && (release = ecs(http.call(env["ECS_CONTAINER_METADATA_URI_V4"])))
40
+ release
32
41
  else
33
42
  new(nil, nil, "unknown")
34
43
  end
@@ -64,6 +73,38 @@ module DeployAngel
64
73
  tag unless tag.empty?
65
74
  end
66
75
 
76
+ # ECS (Fargate, and EC2 with a recent agent) serves each container's
77
+ # metadata at ECS_CONTAINER_METADATA_URI_V4. The image tag identifies the
78
+ # release, and is the commit when it looks like one. A moving tag such
79
+ # as "latest" can't, so the image digest does instead.
80
+ def self.ecs(body)
81
+ data = JSON.parse(body.to_s)
82
+ return unless data.is_a?(Hash)
83
+
84
+ tag = data["Image"].to_s.split("@", 2).first.to_s.split("/").last.to_s.split(":", 2)[1].to_s.strip
85
+ if tag.downcase.match?(COMMIT_FORMAT)
86
+ build(nil, tag, "ecs")
87
+ elsif present?(tag) && !MOVING_TAGS.include?(tag.downcase)
88
+ build(tag, nil, "ecs")
89
+ elsif (digest = data["ImageID"].to_s[/\Asha256:(\h{12})/, 1])
90
+ build("sha256:#{digest}", nil, "ecs")
91
+ end
92
+ rescue JSON::ParserError
93
+ nil
94
+ end
95
+
96
+ # One request at boot to the container's own metadata endpoint, which
97
+ # is local, so the timeouts are short. Nil on any failure.
98
+ def self.fetch_metadata(uri)
99
+ uri = URI.parse(uri)
100
+ Net::HTTP.start(uri.host, uri.port, open_timeout: 0.5, read_timeout: 1) do |http|
101
+ response = http.get(uri.request_uri)
102
+ response.body if response.is_a?(Net::HTTPSuccess)
103
+ end
104
+ rescue StandardError
105
+ nil
106
+ end
107
+
67
108
  # The cloud rejects malformed commits, which would drop every payload,
68
109
  # so anything that is not a hex SHA is left out.
69
110
  def self.build(version, commit, source)
@@ -73,6 +114,12 @@ module DeployAngel
73
114
  source)
74
115
  end
75
116
 
117
+ # HEROKU_BUILD_COMMIT (runtime-dyno-build-metadata) replaces the
118
+ # deprecated HEROKU_SLUG_COMMIT (runtime-dyno-metadata).
119
+ def self.heroku_commit(env)
120
+ present?(env["HEROKU_BUILD_COMMIT"]) ? env["HEROKU_BUILD_COMMIT"] : env["HEROKU_SLUG_COMMIT"]
121
+ end
122
+
76
123
  def self.present?(value)
77
124
  !value.to_s.strip.empty?
78
125
  end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "erb"
5
+ require "yaml"
6
+
7
+ module DeployAngel
8
+ # What the application contains, sent once per process: the route table,
9
+ # job classes, declared recurring schedules, critical flows, and file
10
+ # digests. Paths and hashes only; source code never leaves the
11
+ # application.
12
+ #
13
+ # Routes and job classes are the framework's to answer, so this base
14
+ # reports none and DeployAngel::Rails::Metadata adds them. What no
15
+ # framework owns is gathered here: the schedule files sidekiq-cron and
16
+ # sidekiq-scheduler read, and the file digests.
17
+ class Metadata
18
+ # A Rails-style layout, which most Ruby apps share. An adapter for a
19
+ # framework laid out differently overrides digest_globs.
20
+ DIGEST_GLOBS = %w[app/**/* config/**/* lib/**/* db/migrate/**/* Gemfile.lock].freeze
21
+ MAX_FILES = 20_000
22
+ DIGEST_LENGTH = 16
23
+
24
+ # environment names the deployment ("production"), which the schedulers
25
+ # read to tell whether a job runs here. The agent already knows it, so
26
+ # nothing in this class reaches for a framework to ask.
27
+ def initialize(config:, root:, environment:)
28
+ @config = config
29
+ @root = root
30
+ @environment = environment.to_s
31
+ end
32
+
33
+ def to_protocol
34
+ {
35
+ "routes" => routes,
36
+ "job_classes" => job_classes,
37
+ "schedules" => schedules,
38
+ "critical_flows" => @config.critical_flows.transform_keys(&:to_s).transform_values { |items| Array(items).map(&:to_s) },
39
+ "file_manifest" => file_manifest.slice("hash", "count", "truncated")
40
+ }
41
+ end
42
+
43
+ def files
44
+ file_manifest["files"]
45
+ end
46
+
47
+ # The routes the app serves, as { "key", "controller", "action" }.
48
+ def routes
49
+ []
50
+ end
51
+
52
+ # The job classes the app defines, by name.
53
+ def job_classes
54
+ []
55
+ end
56
+
57
+ # The files digested to tell which routes a release changed, as globs
58
+ # relative to the app's root. Only paths and hashes are sent.
59
+ def digest_globs
60
+ DIGEST_GLOBS
61
+ end
62
+
63
+ # Declared recurring jobs. Each source is read on its own, so a file that
64
+ # can't be read leaves the others' schedules in place.
65
+ def schedules
66
+ sidekiq_cron_schedules + sidekiq_scheduler_schedules
67
+ end
68
+
69
+ # sidekiq-cron jobs from its schedule file (config/schedule.yml unless
70
+ # configured otherwise), which it loads when Sidekiq starts, or from
71
+ # the file in config.sidekiq_cron_schedule_file for an app that loads
72
+ # them itself. Jobs created in code live only in Redis, which the agent
73
+ # doesn't read.
74
+ def sidekiq_cron_schedules
75
+ return [] unless defined?(::Sidekiq::Cron)
76
+
77
+ path = yaml_path(sidekiq_cron_schedule_file) or return []
78
+ jobs = load_yaml(path)
79
+ jobs = jobs.map { |name, job| job.is_a?(Hash) ? job.merge("name" => name) : job } if jobs.is_a?(Hash)
80
+ Array(jobs).filter_map do |job|
81
+ next unless job.is_a?(Hash) && job["cron"] && job["status"].to_s != "disabled"
82
+
83
+ { "key" => job["name"].to_s, "class" => (job["klass"] || job["class"])&.to_s, "schedule" => job["cron"].to_s,
84
+ "source" => "sidekiq_cron", "time_zone" => local_time_zone }
85
+ end
86
+ rescue StandardError
87
+ []
88
+ end
89
+
90
+ def sidekiq_cron_schedule_file
91
+ configured = @config.sidekiq_cron_schedule_file
92
+ configured ||= ::Sidekiq::Cron.configuration.cron_schedule_file if ::Sidekiq::Cron.respond_to?(:configuration)
93
+ File.expand_path(configured || "config/schedule.yml", @root)
94
+ rescue StandardError
95
+ File.join(@root, "config", "schedule.yml")
96
+ end
97
+
98
+ # sidekiq-scheduler jobs from Sidekiq's config file, where it reads
99
+ # them under :scheduler: :schedule: (or :schedule: in older versions).
100
+ # A cron runs at set times; every and interval repeat from when the
101
+ # scheduler starts, which a deploy restarts. One-off at and in jobs
102
+ # aren't recurring, so they're left out.
103
+ def sidekiq_scheduler_schedules
104
+ return [] unless defined?(::SidekiqScheduler)
105
+
106
+ path = sidekiq_config_file or return []
107
+ config = normalize_keys(load_yaml(path) || {})
108
+ config = config.merge(config.delete(@environment) || {}) if config.is_a?(Hash)
109
+ jobs = config.dig("scheduler", "schedule") || config["schedule"]
110
+ return [] unless jobs.is_a?(Hash)
111
+
112
+ jobs.filter_map do |name, job|
113
+ next unless job.is_a?(Hash) && job["enabled"] != false && scheduled_in_this_environment?(job)
114
+
115
+ type = %w[cron every at in interval].find { |key| Array(job[key]).first.to_s.strip != "" }
116
+ next unless %w[cron every interval].include?(type)
117
+
118
+ value = Array(job[type]).first.to_s
119
+ schedule = { "key" => name.to_s, "class" => (job["class"] || name).to_s, "source" => "sidekiq_scheduler",
120
+ "time_zone" => local_time_zone }
121
+ type == "cron" ? schedule.merge("schedule" => value) : schedule.merge("schedule" => nil, "every" => value)
122
+ end
123
+ rescue StandardError
124
+ []
125
+ end
126
+
127
+ # The file Sidekiq loads: the -C path in the Procfile's sidekiq
128
+ # command, else config/sidekiq.yml.
129
+ def sidekiq_config_file
130
+ procfile = File.join(@root, "Procfile")
131
+ named = File.read(procfile)[/\bsidekiq\b[^\n]*?\s(?:-C|--config)[\s=]+(\S+)/, 1] if File.file?(procfile)
132
+ named ? yaml_path(File.expand_path(named, @root)) : yaml_path(File.join(@root, "config", "sidekiq.yml"))
133
+ rescue StandardError
134
+ nil
135
+ end
136
+
137
+ # sidekiq-scheduler skips a job whose rails_env doesn't list this one.
138
+ def scheduled_in_this_environment?(job)
139
+ job["rails_env"].nil? || job["rails_env"].to_s.gsub(/\s/, "").split(",").include?(@environment)
140
+ end
141
+
142
+ # The zone sidekiq-cron and sidekiq-scheduler read a schedule in when it
143
+ # names none: the process's local zone, as Fugit and rufus-scheduler
144
+ # find it. nil if that can't be told.
145
+ def local_time_zone
146
+ ::EtOrbi.determine_local_tzone&.name if defined?(::EtOrbi)
147
+ rescue StandardError
148
+ nil
149
+ end
150
+
151
+ # The path, or the same name with .yaml if only that exists.
152
+ def yaml_path(path)
153
+ [ path, path.sub(/\.yml\z/, ".yaml") ].find { |candidate| File.file?(candidate) }
154
+ end
155
+
156
+ def load_yaml(path)
157
+ YAML.safe_load(ERB.new(File.read(path), trim_mode: "-").result, permitted_classes: [ Symbol ], aliases: true)
158
+ end
159
+
160
+ # Sidekiq's config uses symbol keys (:scheduler:); compare them as
161
+ # plain strings.
162
+ def normalize_keys(object)
163
+ case object
164
+ when Hash then object.to_h { |key, value| [ key.to_s.delete_prefix(":"), normalize_keys(value) ] }
165
+ when Array then object.map { |value| normalize_keys(value) }
166
+ else object
167
+ end
168
+ end
169
+
170
+ def file_manifest
171
+ @file_manifest ||= begin
172
+ if @config.file_digests
173
+ paths = digest_globs.flat_map { |glob| Dir.glob(File.join(@root, glob)) }.select { |path| File.file?(path) }.uniq.sort
174
+ truncated = paths.size > MAX_FILES
175
+ files = paths.first(MAX_FILES).to_h do |path|
176
+ [ path.delete_prefix(@root).delete_prefix("/"), Digest::SHA256.file(path).hexdigest[0, DIGEST_LENGTH] ]
177
+ end
178
+ hash = Digest::SHA256.hexdigest(files.map { |path, digest| "#{path}:#{digest}" }.join("\n"))
179
+ { "hash" => hash, "count" => files.size, "truncated" => truncated, "files" => files }
180
+ else
181
+ { "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
182
+ end
183
+ end
184
+ rescue StandardError
185
+ { "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
186
+ end
187
+ end
188
+ end
@@ -0,0 +1,112 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeployAngel
4
+ # Rack adapters. Inside this namespace a bare Rack means DeployAngel::Rack,
5
+ # so reach for the Rack gem itself as ::Rack.
6
+ module Rack
7
+ # The base for a framework's request middleware, not a middleware to use
8
+ # on its own: without an adapter naming routes, every successful request
9
+ # is unrouted and skipped, and only errors are recorded, as "unmatched".
10
+ # An adapter subclasses it, answers route_pattern (and rendered_exception
11
+ # if the framework renders exceptions itself), and is inserted at the top
12
+ # of the stack, so it sees the final status after the framework's own
13
+ # error pages. DeployAngel::Rails::Http is the Rails adapter.
14
+ #
15
+ # A request is only counted against a route pattern the framework
16
+ # matched, never the raw path, which keeps IDs out of route keys.
17
+ class Http
18
+ # Health checks served by a lambda or a mounted Rack app at a
19
+ # conventional path. Load balancers and uptime monitors call them all
20
+ # the time and they answer fast, so counting them would make any app
21
+ # look busy and healthy. Others can be left out with
22
+ # config.ignored_routes.
23
+ HEALTH_CHECK_PATHS = %w[up health healthz healthcheck health_check livez readyz statusz ping].freeze
24
+
25
+ def initialize(app)
26
+ @app = app
27
+ end
28
+
29
+ def call(env)
30
+ return @app.call(env) unless DeployAngel.recording?
31
+
32
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
+ Redaction.request_host = env["HTTP_HOST"]
34
+ begin
35
+ status, headers, body = @app.call(env)
36
+ rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
37
+ record(env, 500, started, unhandled: true, exception: e)
38
+ raise
39
+ end
40
+ # A framework that renders an exception itself swallows it before it
41
+ # reaches this middleware, and renders some as 4xx; only those that
42
+ # end in a 5xx count as unhandled.
43
+ exception = rendered_exception(env) if status.to_i >= 500
44
+ record(env, status, started, unhandled: !exception.nil?, exception: exception)
45
+ [ status, headers, body ]
46
+ end
47
+
48
+ private
49
+ # The route the framework matched, as a pattern with placeholders
50
+ # ("/users/:id"), or nil when nothing matched. Placeholders may be
51
+ # spelled :name, *name, or {name}; health_check? reads them, and
52
+ # otherwise the pattern is only ever compared with itself.
53
+ def route_pattern(env)
54
+ nil
55
+ end
56
+
57
+ # An exception the framework caught and rendered itself, or nil. Only
58
+ # asked for on a 5xx.
59
+ def rendered_exception(env)
60
+ nil
61
+ end
62
+
63
+ def route_key(env)
64
+ pattern = route_pattern(env) or return nil
65
+
66
+ "#{env["REQUEST_METHOD"]} #{pattern}"
67
+ end
68
+
69
+ # A static route ending in a health-check name, such as "/healthz" or
70
+ # "/api/livez". A route with a placeholder, such as
71
+ # "/patients/:id/health", is a real page about something, so it's
72
+ # recorded.
73
+ def health_check?(env)
74
+ pattern = route_pattern(env) or return false
75
+
76
+ segments = pattern.split("/").reject(&:empty?)
77
+ HEALTH_CHECK_PATHS.include?(segments.last) && segments.none? { |segment| placeholder?(segment) }
78
+ end
79
+
80
+ def placeholder?(segment)
81
+ segment.start_with?(":", "*") || segment.include?("{")
82
+ end
83
+
84
+ def record(env, status, started, unhandled:, exception: nil)
85
+ return if health_check?(env)
86
+
87
+ route = route_key(env)
88
+ return if route && DeployAngel.configuration.ignored_route?(route)
89
+ # Unrouted successes are static files and similar; unrouted
90
+ # errors (such as routing 404s) are still recorded.
91
+ return if route.nil? && status.to_i < 400
92
+
93
+ key = route || "#{env["REQUEST_METHOD"]} unmatched"
94
+ DeployAngel.record_exception(exception, source: "route:#{key}") if exception
95
+ DeployAngel.record_request(
96
+ route_key: key,
97
+ status: status.to_i,
98
+ duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0,
99
+ unhandled: unhandled,
100
+ # A 4xx no route matched is mostly bots probing paths like
101
+ # /wp-admin, or middleware turning requests away. It stays
102
+ # visible under "unmatched", but out of the app's totals, so it
103
+ # doesn't add to the evidence or dilute real pages' latency. An
104
+ # unrouted 5xx still counts: something broke.
105
+ in_totals: !route.nil? || status.to_i >= 500
106
+ )
107
+ rescue StandardError
108
+ nil
109
+ end
110
+ end
111
+ end
112
+ end
@@ -2,91 +2,53 @@
2
2
 
3
3
  module DeployAngel
4
4
  module Rails
5
- # Rack middleware at the top of the stack, so it sees the final status
6
- # after Rails renders exceptions. Records the matched route pattern,
7
- # never the raw path, which keeps IDs out of route keys.
8
- class Http
5
+ # The Rails adapter: route identity from Action Dispatch, and the
6
+ # exceptions Rails renders itself. Everything else about recording a
7
+ # request is in DeployAngel::Rack::Http.
8
+ class Http < ::DeployAngel::Rack::Http
9
9
  FORMAT_SUFFIX = "(.:format)"
10
10
  # Health checks: Rails' own (/up by default), OkComputer, health_check,
11
- # and rails-healthcheck, wherever they're mounted. Load balancers and
12
- # uptime monitors call them all the time and they answer fast, so
13
- # counting them would make any app look busy and healthy. Others can
14
- # be left out with config.ignored_routes.
11
+ # and rails-healthcheck, wherever they're mounted. A lambda or a
12
+ # mounted Rack app at a conventional path is left out by the Rack
13
+ # adapter's HEALTH_CHECK_PATHS.
15
14
  HEALTH_CHECK_CONTROLLERS = %w[rails/health ok_computer/ok_computer health_check/health_check healthcheck/healthchecks].freeze
16
- # Health checks served by a lambda or a mounted Rack app rather than a
17
- # controller, at a conventional path such as /healthz.
18
- HEALTH_CHECK_PATHS = %w[up health healthz healthcheck health_check livez readyz statusz ping].freeze
19
-
20
- def initialize(app)
21
- @app = app
22
- end
23
-
24
- def call(env)
25
- return @app.call(env) unless DeployAngel.recording?
26
-
27
- started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
28
- begin
29
- status, headers, body = @app.call(env)
30
- rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
31
- record(env, 500, started, unhandled: true, exception: e)
32
- raise
33
- end
34
- # Rails renders some exceptions as 4xx (routing errors, RecordNotFound,
35
- # ParameterMissing); only those that end in a 5xx count as unhandled.
36
- exception = env["action_dispatch.exception"] if status.to_i >= 500
37
- record(env, status, started, unhandled: !exception.nil?, exception: exception)
38
- [ status, headers, body ]
39
- end
40
15
 
41
16
  private
42
- def record(env, status, started, unhandled:, exception: nil)
43
- return if health_check?(env)
44
-
45
- route = route_key(env)
46
- return if route && DeployAngel.configuration.ignored_route?(route)
47
- # Unrouted successes are static files and similar; unrouted
48
- # errors (such as routing 404s) are still recorded.
49
- return if route.nil? && status.to_i < 400
17
+ def route_pattern(env)
18
+ pattern = env["action_dispatch.route_uri_pattern"] || env["action_dispatch.route"]&.path&.spec&.to_s
19
+ pattern&.delete_suffix(FORMAT_SUFFIX)
20
+ end
50
21
 
51
- key = route || "#{env["REQUEST_METHOD"]} unmatched"
52
- DeployAngel.record_exception(exception, source: "route:#{key}") if exception
53
- DeployAngel.record_request(
54
- route_key: key,
55
- status: status.to_i,
56
- duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0,
57
- unhandled: unhandled,
58
- # A 4xx no route matched is mostly bots probing paths like
59
- # /wp-admin, or middleware turning requests away. It stays
60
- # visible under "unmatched", but out of the app's totals, so it
61
- # doesn't add to the evidence or dilute real pages' latency. An
62
- # unrouted 5xx still counts: something broke.
63
- in_totals: !route.nil? || status.to_i >= 500
64
- )
65
- rescue StandardError
66
- nil
22
+ # Rails renders some exceptions as 4xx (routing errors,
23
+ # RecordNotFound, ParameterMissing), which is why only a 5xx asks.
24
+ def rendered_exception(env)
25
+ env["action_dispatch.exception"]
67
26
  end
68
27
 
69
28
  # A controller at a health-check path may be a real page, so only
70
- # controllerless routes are judged by their path.
29
+ # controllerless routes (a lambda or a mounted Rack app) are judged by
30
+ # their path, by its last segment as in 0.1.8. A controller decides
31
+ # anything else, so Rails needs none of the Rack base's placeholder
32
+ # rule.
71
33
  def health_check?(env)
72
- controller = env["action_dispatch.request.path_parameters"]&.dig(:controller)
34
+ controller = controller_name(env)
73
35
  return HEALTH_CHECK_CONTROLLERS.include?(controller) if controller
74
36
 
75
37
  pattern = route_pattern(env) or return false
76
- HEALTH_CHECK_PATHS.include?(pattern.delete_suffix(FORMAT_SUFFIX).split("/").last)
38
+ HEALTH_CHECK_PATHS.include?(pattern.split("/").last)
77
39
  end
78
40
 
41
+ # The matched pattern, else the controller and action, which a route
42
+ # reached without one still has.
79
43
  def route_key(env)
80
- method = env["REQUEST_METHOD"]
81
- if (pattern = route_pattern(env))
82
- "#{method} #{pattern.delete_suffix(FORMAT_SUFFIX)}"
83
- elsif (params = env["action_dispatch.request.path_parameters"]) && params[:controller]
84
- "#{method} #{params[:controller]}##{params[:action]}"
44
+ super || begin
45
+ controller = controller_name(env)
46
+ "#{env["REQUEST_METHOD"]} #{controller}##{env["action_dispatch.request.path_parameters"][:action]}" if controller
85
47
  end
86
48
  end
87
49
 
88
- def route_pattern(env)
89
- env["action_dispatch.route_uri_pattern"] || env["action_dispatch.route"]&.path&.spec&.to_s
50
+ def controller_name(env)
51
+ env["action_dispatch.request.path_parameters"]&.dig(:controller)
90
52
  end
91
53
  end
92
54
  end
@@ -1,39 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "digest"
4
- require "erb"
5
- require "yaml"
6
-
7
3
  module DeployAngel
8
4
  module Rails
9
- # What the application contains, sent once per process: the route table,
10
- # job classes, declared recurring schedules, critical flows, and file
11
- # digests. Paths and hashes only; source code never leaves the
12
- # application.
13
- class Metadata
14
- DIGEST_GLOBS = %w[app/**/* config/**/* lib/**/* db/migrate/**/* Gemfile.lock].freeze
15
- MAX_FILES = 20_000
16
- DIGEST_LENGTH = 16
5
+ # The Rails adapter: the route table from the router, job classes from
6
+ # Active Job, and Solid Queue's recurring tasks. The schedule files
7
+ # sidekiq-cron and sidekiq-scheduler read, and the file digests, are
8
+ # gathered in DeployAngel::Metadata.
9
+ class Metadata < ::DeployAngel::Metadata
17
10
  FORMAT_SUFFIX = "(.:format)"
18
11
 
19
- def initialize(app:, config:, root:)
12
+ def initialize(app:, config:, root:, environment: nil)
13
+ super(config: config, root: root, environment: environment || rails_environment)
20
14
  @app = app
21
- @config = config
22
- @root = root
23
- end
24
-
25
- def to_protocol
26
- {
27
- "routes" => routes,
28
- "job_classes" => job_classes,
29
- "schedules" => schedules,
30
- "critical_flows" => @config.critical_flows.transform_keys(&:to_s).transform_values { |items| Array(items).map(&:to_s) },
31
- "file_manifest" => file_manifest.slice("hash", "count", "truncated")
32
- }
33
15
  end
34
16
 
35
- def files
36
- file_manifest["files"]
17
+ # Rails names the deployment, but this can be built with Rails not
18
+ # loaded (the bench does), and an unknown environment is better than a
19
+ # constructor that raises.
20
+ def rails_environment
21
+ defined?(::Rails) && ::Rails.respond_to?(:env) ? ::Rails.env.to_s : ""
37
22
  end
38
23
 
39
24
  def routes
@@ -59,11 +44,10 @@ module DeployAngel
59
44
  []
60
45
  end
61
46
 
62
- # Declared recurring jobs from Solid Queue, sidekiq-cron, and
63
- # sidekiq-scheduler. Each source is read on its own, so a file that
64
- # can't be read leaves the others' schedules in place.
47
+ # Solid Queue's schedule is Rails' own; the rest are read the same way
48
+ # on every framework.
65
49
  def schedules
66
- solid_queue_schedules + sidekiq_cron_schedules + sidekiq_scheduler_schedules
50
+ solid_queue_schedules + super
67
51
  end
68
52
 
69
53
  # Solid Queue recurring tasks for the current environment.
@@ -72,7 +56,7 @@ module DeployAngel
72
56
  return [] unless File.file?(path)
73
57
 
74
58
  config = YAML.safe_load(ERB.new(File.read(path)).result, aliases: true) || {}
75
- tasks = config.key?(::Rails.env) ? config[::Rails.env] : config
59
+ tasks = config.key?(@environment) ? config[@environment] : config
76
60
  Array(tasks).filter_map do |key, task|
77
61
  next unless task.is_a?(Hash) && task["schedule"]
78
62
 
@@ -85,107 +69,6 @@ module DeployAngel
85
69
  []
86
70
  end
87
71
 
88
- # sidekiq-cron jobs from its schedule file (config/schedule.yml unless
89
- # configured otherwise), which it loads when Sidekiq starts, or from
90
- # the file in config.sidekiq_cron_schedule_file for an app that loads
91
- # them itself. Jobs created in code live only in Redis, which the agent
92
- # doesn't read.
93
- def sidekiq_cron_schedules
94
- return [] unless defined?(::Sidekiq::Cron)
95
-
96
- path = yaml_path(sidekiq_cron_schedule_file) or return []
97
- jobs = load_yaml(path)
98
- jobs = jobs.map { |name, job| job.is_a?(Hash) ? job.merge("name" => name) : job } if jobs.is_a?(Hash)
99
- Array(jobs).filter_map do |job|
100
- next unless job.is_a?(Hash) && job["cron"] && job["status"].to_s != "disabled"
101
-
102
- { "key" => job["name"].to_s, "class" => (job["klass"] || job["class"])&.to_s, "schedule" => job["cron"].to_s,
103
- "source" => "sidekiq_cron", "time_zone" => local_time_zone }
104
- end
105
- rescue StandardError
106
- []
107
- end
108
-
109
- def sidekiq_cron_schedule_file
110
- configured = @config.sidekiq_cron_schedule_file
111
- configured ||= ::Sidekiq::Cron.configuration.cron_schedule_file if ::Sidekiq::Cron.respond_to?(:configuration)
112
- File.expand_path(configured || "config/schedule.yml", @root)
113
- rescue StandardError
114
- File.join(@root, "config", "schedule.yml")
115
- end
116
-
117
- # sidekiq-scheduler jobs from Sidekiq's config file, where it reads
118
- # them under :scheduler: :schedule: (or :schedule: in older versions).
119
- # A cron runs at set times; every and interval repeat from when the
120
- # scheduler starts, which a deploy restarts. One-off at and in jobs
121
- # aren't recurring, so they're left out.
122
- def sidekiq_scheduler_schedules
123
- return [] unless defined?(::SidekiqScheduler)
124
-
125
- path = sidekiq_config_file or return []
126
- config = normalize_keys(load_yaml(path) || {})
127
- config = config.merge(config.delete(::Rails.env.to_s) || {}) if config.is_a?(Hash)
128
- jobs = config.dig("scheduler", "schedule") || config["schedule"]
129
- return [] unless jobs.is_a?(Hash)
130
-
131
- jobs.filter_map do |name, job|
132
- next unless job.is_a?(Hash) && job["enabled"] != false && scheduled_in_this_environment?(job)
133
-
134
- type = %w[cron every at in interval].find { |key| Array(job[key]).first.to_s.strip != "" }
135
- next unless %w[cron every interval].include?(type)
136
-
137
- value = Array(job[type]).first.to_s
138
- schedule = { "key" => name.to_s, "class" => (job["class"] || name).to_s, "source" => "sidekiq_scheduler",
139
- "time_zone" => local_time_zone }
140
- type == "cron" ? schedule.merge("schedule" => value) : schedule.merge("schedule" => nil, "every" => value)
141
- end
142
- rescue StandardError
143
- []
144
- end
145
-
146
- # The file Sidekiq loads: the -C path in the Procfile's sidekiq
147
- # command, else config/sidekiq.yml.
148
- def sidekiq_config_file
149
- procfile = File.join(@root, "Procfile")
150
- named = File.read(procfile)[/\bsidekiq\b[^\n]*?\s(?:-C|--config)[\s=]+(\S+)/, 1] if File.file?(procfile)
151
- named ? yaml_path(File.expand_path(named, @root)) : yaml_path(File.join(@root, "config", "sidekiq.yml"))
152
- rescue StandardError
153
- nil
154
- end
155
-
156
- # sidekiq-scheduler skips a job whose rails_env doesn't list this one.
157
- def scheduled_in_this_environment?(job)
158
- job["rails_env"].nil? || job["rails_env"].to_s.gsub(/\s/, "").split(",").include?(::Rails.env.to_s)
159
- end
160
-
161
- # The zone sidekiq-cron and sidekiq-scheduler read a schedule in when it
162
- # names none: the process's local zone, as Fugit and rufus-scheduler
163
- # find it. nil if that can't be told.
164
- def local_time_zone
165
- ::EtOrbi.determine_local_tzone&.name if defined?(::EtOrbi)
166
- rescue StandardError
167
- nil
168
- end
169
-
170
- # The path, or the same name with .yaml if only that exists.
171
- def yaml_path(path)
172
- [ path, path.sub(/\.yml\z/, ".yaml") ].find { |candidate| File.file?(candidate) }
173
- end
174
-
175
- def load_yaml(path)
176
- YAML.safe_load(ERB.new(File.read(path), trim_mode: "-").result, permitted_classes: [ Symbol ], aliases: true)
177
- end
178
-
179
- # Sidekiq's config uses symbol keys (:scheduler:); compare them as
180
- # plain strings.
181
- def normalize_keys(object)
182
- case object
183
- when Hash then object.to_h { |key, value| [ key.to_s.delete_prefix(":"), normalize_keys(value) ] }
184
- when Array then object.map { |value| normalize_keys(value) }
185
- else object
186
- end
187
- end
188
-
189
72
  # A command task runs as a job of this class, so its runs show up in
190
73
  # job telemetry under that name.
191
74
  def command_job_class
@@ -205,24 +88,6 @@ module DeployAngel
205
88
  rescue StandardError
206
89
  nil
207
90
  end
208
-
209
- def file_manifest
210
- @file_manifest ||= begin
211
- if @config.file_digests
212
- paths = DIGEST_GLOBS.flat_map { |glob| Dir.glob(File.join(@root, glob)) }.select { |path| File.file?(path) }.sort
213
- truncated = paths.size > MAX_FILES
214
- files = paths.first(MAX_FILES).to_h do |path|
215
- [ path.delete_prefix(@root).delete_prefix("/"), Digest::SHA256.file(path).hexdigest[0, DIGEST_LENGTH] ]
216
- end
217
- hash = Digest::SHA256.hexdigest(files.map { |path, digest| "#{path}:#{digest}" }.join("\n"))
218
- { "hash" => hash, "count" => files.size, "truncated" => truncated, "files" => files }
219
- else
220
- { "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
221
- end
222
- end
223
- rescue StandardError
224
- { "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
225
- end
226
91
  end
227
92
  end
228
93
  end
@@ -21,6 +21,7 @@ module DeployAngel
21
21
  end
22
22
 
23
23
  config.after_initialize do |app|
24
+ DeployAngel::Apartment.install if DeployAngel.configuration.exception_messages
24
25
  agent = DeployAngel.start(
25
26
  environment: ::Rails.env,
26
27
  root: ::Rails.root.to_s,
@@ -28,7 +29,7 @@ module DeployAngel
28
29
  framework_version: ::Rails.version,
29
30
  logger: ::Rails.logger
30
31
  )
31
- agent&.metadata = DeployAngel::Rails::Metadata.new(app: app, config: DeployAngel.configuration, root: ::Rails.root.to_s)
32
+ agent&.metadata = DeployAngel::Rails::Metadata.new(app: app, config: DeployAngel.configuration, root: ::Rails.root.to_s, environment: ::Rails.env)
32
33
  end
33
34
  end
34
35
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.9"
4
+ VERSION = "0.1.11"
5
5
  end
data/lib/deployangel.rb CHANGED
@@ -3,6 +3,7 @@
3
3
  require_relative "deployangel/version"
4
4
  require_relative "deployangel/configuration"
5
5
  require_relative "deployangel/core/histogram"
6
+ require_relative "deployangel/core/redaction"
6
7
  require_relative "deployangel/core/fingerprint"
7
8
  require_relative "deployangel/core/release"
8
9
  require_relative "deployangel/core/instance"
@@ -110,9 +111,12 @@ module DeployAngel
110
111
  end
111
112
  end
112
113
 
114
+ require_relative "deployangel/rack/http"
113
115
  require_relative "deployangel/rails/http"
114
116
  require_relative "deployangel/rails/active_job"
115
117
  require_relative "deployangel/rails/error_subscriber"
118
+ require_relative "deployangel/metadata"
116
119
  require_relative "deployangel/rails/metadata"
117
120
  require_relative "deployangel/sidekiq"
121
+ require_relative "deployangel/apartment"
118
122
  require_relative "deployangel/rails/railtie" if defined?(::Rails::Railtie)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: deployangel
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.9
4
+ version: 0.1.11
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Owens
@@ -9,9 +9,11 @@ bindir: exe
9
9
  cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
- description: Aggregates HTTP request behavior in-process and reports one small payload
13
- per process per minute to DeployAngel, which verifies each deployment and tells
14
- you when it is safe to stop watching it.
12
+ description: Aggregates HTTP requests, background jobs, exceptions, and recurring
13
+ schedules in-process and reports one small payload per process per minute to DeployAngel,
14
+ which verifies each deployment and tells you, or your coding agent, when a release
15
+ is cleared and safe to stop watching. It never sends request bodies, parameters,
16
+ SQL, or user data.
15
17
  email:
16
18
  - jordan@deployangel.com
17
19
  executables:
@@ -25,6 +27,7 @@ files:
25
27
  - exe/deployangel
26
28
  - lib/deployangel.rb
27
29
  - lib/deployangel/agent.rb
30
+ - lib/deployangel/apartment.rb
28
31
  - lib/deployangel/capistrano.rb
29
32
  - lib/deployangel/capistrano/steps.rb
30
33
  - lib/deployangel/capistrano/tasks.rake
@@ -39,10 +42,13 @@ files:
39
42
  - lib/deployangel/core/histogram.rb
40
43
  - lib/deployangel/core/instance.rb
41
44
  - lib/deployangel/core/protocol.rb
45
+ - lib/deployangel/core/redaction.rb
42
46
  - lib/deployangel/core/release.rb
43
47
  - lib/deployangel/core/transport.rb
44
48
  - lib/deployangel/fork_hook.rb
45
49
  - lib/deployangel/mcp.rb
50
+ - lib/deployangel/metadata.rb
51
+ - lib/deployangel/rack/http.rb
46
52
  - lib/deployangel/rails/active_job.rb
47
53
  - lib/deployangel/rails/error_subscriber.rb
48
54
  - lib/deployangel/rails/http.rb