deployangel 0.1.9 → 0.1.10
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 +14 -0
- data/README.md +37 -8
- data/lib/deployangel/agent.rb +3 -1
- data/lib/deployangel/apartment.rb +30 -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 +43 -2
- data/lib/deployangel/rails/http.rb +1 -0
- data/lib/deployangel/rails/railtie.rb +1 -0
- data/lib/deployangel/version.rb +1 -1
- data/lib/deployangel.rb +2 -0
- metadata +3 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2fd5de9ae625d8780f853e4ee71b662be91f6dc702f59aa15806b359c075045a
|
|
4
|
+
data.tar.gz: 5823184602177d55b1abc39dd920b01879162a335afabe3dee46c5cdbfec1295
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 5c899072c049b8854d5772c05be7cb95588d427a571324ab303134418081b3815ae39eb6efaea92b1e46f6857f24379674f9298050435638bb674baca942aeb7
|
|
7
|
+
data.tar.gz: 3ce76ca3b6232505e4d1afaa95348939a1ffcac19fe02cf296f30fdf02fd447aba85991c5d8b292ace00d19b38a0e73418a668d0cef8993a86119a1a3d2abc26
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.10 (2026-10-04)
|
|
4
|
+
|
|
5
|
+
- `config.exception_messages = false` (or `DEPLOYANGEL_EXCEPTION_MESSAGES=false`)
|
|
6
|
+
sends exceptions with their class, fingerprint, and application frames, and
|
|
7
|
+
no message. Grouping and verdicts work the same.
|
|
8
|
+
- The request's host is replaced with `<host>` in exception messages, and with
|
|
9
|
+
ros-apartment, so are the tenants the request or job switched to
|
|
10
|
+
(`<tenant>`), including one that turned out not to exist. The default tenant
|
|
11
|
+
is left alone.
|
|
12
|
+
- ECS, including Fargate: the release comes from the container's image, read
|
|
13
|
+
once at boot from `ECS_CONTAINER_METADATA_URI_V4`. A tag that's a commit is
|
|
14
|
+
the commit, another tag is the version, and a moving tag such as `latest` uses
|
|
15
|
+
the image digest. `DEPLOYANGEL_REVISION` and a `REVISION` file still win.
|
|
16
|
+
|
|
3
17
|
## 0.1.9 (2026-10-04)
|
|
4
18
|
|
|
5
19
|
- A 4xx response to a request no route matched, such as a bot probing
|
data/README.md
CHANGED
|
@@ -34,9 +34,14 @@ The agent must know which release it is running. It finds it in this order:
|
|
|
34
34
|
didn't come from GitHub
|
|
35
35
|
7. Coolify: `SOURCE_COMMIT`. Dokku: `GIT_REV`
|
|
36
36
|
8. a `REVISION` file in the app root, which Capistrano writes and any build step can
|
|
37
|
+
9. ECS, including Fargate: the container's image, from the metadata endpoint ECS
|
|
38
|
+
provides (one local request at boot). The image tag is the release when it's a
|
|
39
|
+
commit or a version like `v1.4.2`; with a moving tag like `latest`, the image
|
|
40
|
+
digest is
|
|
37
41
|
|
|
38
|
-
For other Docker deploys (compose, Swarm,
|
|
39
|
-
the image, since `.dockerignore`
|
|
42
|
+
For other Docker deploys (compose, Swarm, Kubernetes), and on ECS to see each
|
|
43
|
+
release by its commit, bake the commit into the image, since `.dockerignore`
|
|
44
|
+
usually leaves `.git` out:
|
|
40
45
|
|
|
41
46
|
```dockerfile
|
|
42
47
|
ARG GIT_SHA
|
|
@@ -63,9 +68,13 @@ says so.
|
|
|
63
68
|
middleware. Failures that `retry_on` or `discard_on` handle still count.
|
|
64
69
|
- Release identity, runtime versions, and a per-process instance ID.
|
|
65
70
|
|
|
66
|
-
- Exceptions: a stable fingerprint, the exception class,
|
|
67
|
-
|
|
68
|
-
|
|
71
|
+
- Exceptions: a stable fingerprint, the exception class, the first line of the
|
|
72
|
+
message, and application frames only. In the message, numbers, IDs, emails,
|
|
73
|
+
UUIDs, long hex strings, and quoted values are replaced with placeholders, and
|
|
74
|
+
so are the request's host and, with ros-apartment, the tenants the request or
|
|
75
|
+
job switched to. Other words are kept: `Payment failed for Jane Doe` is sent
|
|
76
|
+
as it is. If your app's messages might hold personal or health data, [turn
|
|
77
|
+
messages off](#exception-messages).
|
|
69
78
|
- Once per process: the route table, job classes, recurring schedules
|
|
70
79
|
declared for Solid Queue, sidekiq-cron, or sidekiq-scheduler, critical
|
|
71
80
|
flows, and file digests (relative paths and hashes, never file contents) so
|
|
@@ -140,6 +149,29 @@ DeployAngel.configure do |config|
|
|
|
140
149
|
end
|
|
141
150
|
```
|
|
142
151
|
|
|
152
|
+
`DEPLOYANGEL_ENABLED=true|false` forces reporting on or off in any environment.
|
|
153
|
+
`DEPLOYANGEL_URL` overrides the API endpoint (default `https://api.deployangel.com`).
|
|
154
|
+
|
|
155
|
+
### Exception messages
|
|
156
|
+
|
|
157
|
+
To send exceptions without any message, only their class, fingerprint, and
|
|
158
|
+
application frames:
|
|
159
|
+
|
|
160
|
+
```ruby
|
|
161
|
+
DeployAngel.configure do |config|
|
|
162
|
+
config.exception_messages = false # or DEPLOYANGEL_EXCEPTION_MESSAGES=false
|
|
163
|
+
end
|
|
164
|
+
```
|
|
165
|
+
|
|
166
|
+
Grouping, new-exception detection, and verdicts work the same, since the
|
|
167
|
+
fingerprint never uses the message. You lose the message text in the dashboard,
|
|
168
|
+
notifications, and AI investigation. With messages off, what leaves your app is
|
|
169
|
+
route patterns, counts, timings, job class names, exception classes, and
|
|
170
|
+
application file paths and method names, plus the route table, job classes,
|
|
171
|
+
schedules, and file digests described above.
|
|
172
|
+
|
|
173
|
+
### Recurring jobs
|
|
174
|
+
|
|
143
175
|
DeployAngel expects declared recurring jobs on schedule. It reads Solid Queue's
|
|
144
176
|
`config/recurring.yml`, sidekiq-scheduler's section of Sidekiq's config, and
|
|
145
177
|
sidekiq-cron's schedule file (`config/schedule.yml` unless sidekiq-cron is
|
|
@@ -156,9 +188,6 @@ end
|
|
|
156
188
|
Jobs that exist only in Redis, such as ones created in code or in the
|
|
157
189
|
Sidekiq web UI, aren't read.
|
|
158
190
|
|
|
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
191
|
## Checkpoints
|
|
163
192
|
|
|
164
193
|
Errors and latency don't catch work that silently stops happening. Count the
|
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
|
|
@@ -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,15 +1,22 @@
|
|
|
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
22
|
elsif present?(env["HEROKU_RELEASE_VERSION"]) || present?(env["HEROKU_SLUG_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)
|
|
@@ -25,6 +25,7 @@ module DeployAngel
|
|
|
25
25
|
return @app.call(env) unless DeployAngel.recording?
|
|
26
26
|
|
|
27
27
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
28
|
+
Redaction.request_host = env["HTTP_HOST"]
|
|
28
29
|
begin
|
|
29
30
|
status, headers, body = @app.call(env)
|
|
30
31
|
rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
|
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"
|
|
@@ -115,4 +116,5 @@ require_relative "deployangel/rails/active_job"
|
|
|
115
116
|
require_relative "deployangel/rails/error_subscriber"
|
|
116
117
|
require_relative "deployangel/rails/metadata"
|
|
117
118
|
require_relative "deployangel/sidekiq"
|
|
119
|
+
require_relative "deployangel/apartment"
|
|
118
120
|
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.10
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jordan Owens
|
|
@@ -25,6 +25,7 @@ files:
|
|
|
25
25
|
- exe/deployangel
|
|
26
26
|
- lib/deployangel.rb
|
|
27
27
|
- lib/deployangel/agent.rb
|
|
28
|
+
- lib/deployangel/apartment.rb
|
|
28
29
|
- lib/deployangel/capistrano.rb
|
|
29
30
|
- lib/deployangel/capistrano/steps.rb
|
|
30
31
|
- lib/deployangel/capistrano/tasks.rake
|
|
@@ -39,6 +40,7 @@ files:
|
|
|
39
40
|
- lib/deployangel/core/histogram.rb
|
|
40
41
|
- lib/deployangel/core/instance.rb
|
|
41
42
|
- lib/deployangel/core/protocol.rb
|
|
43
|
+
- lib/deployangel/core/redaction.rb
|
|
42
44
|
- lib/deployangel/core/release.rb
|
|
43
45
|
- lib/deployangel/core/transport.rb
|
|
44
46
|
- lib/deployangel/fork_hook.rb
|