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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: e2bc6936b563039a1c2caa91f3a8125f0c32582366505ab35dfabf2ec90af3c5
4
- data.tar.gz: 22c805b0adef58922b3513afc19492433421358be2e75fd12513e650a7e57c9c
3
+ metadata.gz: 2fd5de9ae625d8780f853e4ee71b662be91f6dc702f59aa15806b359c075045a
4
+ data.tar.gz: 5823184602177d55b1abc39dd920b01879162a335afabe3dee46c5cdbfec1295
5
5
  SHA512:
6
- metadata.gz: 361b042f1d885fc63442dff6d4f4c406b36fd14ea5c226cf04a5bea2f7537770c6b4f4ad169331b95e6f3bdacf37169f90ac70fc6567006203f4884fe021236a
7
- data.tar.gz: 328b709591c7c5441390fb74dc535d0a515061fd64161bbe555ffe9f4f8602bf32f10676b6119458e44837ee539e96e5e2f4d72b97cafc05e203d3ef2eb77e7b
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, ECS, Kubernetes), bake the commit into
39
- the image, since `.dockerignore` usually leaves `.git` out:
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, a sanitized message
67
- (numbers, IDs, emails, and quoted values removed), and application frames
68
- only.
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
@@ -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
@@ -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,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), 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
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
@@ -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,
@@ -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.10"
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"
@@ -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.9
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