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 +4 -4
- data/CHANGELOG.md +24 -0
- data/README.md +42 -8
- data/lib/deployangel/agent.rb +3 -1
- data/lib/deployangel/apartment.rb +30 -0
- data/lib/deployangel/cli/formatter.rb +103 -33
- data/lib/deployangel/cli.rb +12 -0
- data/lib/deployangel/configuration.rb +4 -1
- data/lib/deployangel/core/fingerprint.rb +10 -3
- data/lib/deployangel/core/redaction.rb +40 -0
- data/lib/deployangel/core/release.rb +51 -4
- data/lib/deployangel/metadata.rb +188 -0
- data/lib/deployangel/rack/http.rb +112 -0
- data/lib/deployangel/rails/http.rb +28 -66
- data/lib/deployangel/rails/metadata.rb +16 -151
- data/lib/deployangel/rails/railtie.rb +2 -1
- data/lib/deployangel/version.rb +1 -1
- data/lib/deployangel.rb +4 -0
- metadata +10 -4
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 832e7ada784cf0e86d34f802ef299dfd9114b844f542444a69331b7d622e3d35
|
|
4
|
+
data.tar.gz: 31c8f9e2efc46a96325e7813c8b5902c23cbf758c55dccdb3536048af881f7a7
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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,
|
|
39
|
-
the image, since `.dockerignore`
|
|
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,
|
|
67
|
-
|
|
68
|
-
|
|
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
|
data/lib/deployangel/agent.rb
CHANGED
|
@@ -91,7 +91,9 @@ module DeployAngel
|
|
|
91
91
|
|
|
92
92
|
exception.instance_variable_set(SEEN, true) unless exception.frozen?
|
|
93
93
|
start_reporter
|
|
94
|
-
|
|
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
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
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
|
-
|
|
31
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
|
40
|
-
problems.each { |f| lines << "
|
|
42
|
+
lines.push("", "| Status | Finding |", "| --- | --- |")
|
|
43
|
+
problems.each { |f| lines << "| #{f["status"]} | #{cell(finding_line(f))} |" }
|
|
41
44
|
end
|
|
42
|
-
gathering =
|
|
43
|
-
|
|
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
|
-
|
|
48
|
-
|
|
48
|
+
sections(document).each do |title, items|
|
|
49
|
+
lines.push("", "**#{title}**", "")
|
|
50
|
+
items.each { |item| lines << "- #{item}" }
|
|
49
51
|
end
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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 << " #{
|
|
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)
|
data/lib/deployangel/cli.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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),
|
|
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
|
|
16
|
-
build(env["HEROKU_RELEASE_VERSION"], env
|
|
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
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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.
|
|
12
|
-
#
|
|
13
|
-
#
|
|
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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
"#{
|
|
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
|
|
89
|
-
env["action_dispatch.
|
|
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
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
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
|
-
|
|
36
|
-
|
|
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
|
-
#
|
|
63
|
-
#
|
|
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 +
|
|
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?(
|
|
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
|
data/lib/deployangel/version.rb
CHANGED
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.
|
|
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
|
|
13
|
-
per process per minute to DeployAngel,
|
|
14
|
-
you
|
|
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
|