deployangel 0.1.35 → 0.1.37

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: 275f765f13eee93ddc36007727d1f2278b343a524562c062eeab1a258f8abb16
4
- data.tar.gz: f167ad720f710b5cf3a527632795972a5dbbe972309bbbf470a937425d0282e7
3
+ metadata.gz: 618c24521dbd9aa6102fdd7c3a6c105494abcde74072fa37d8bd2e27f2737f49
4
+ data.tar.gz: 55e402796d95fe4aee557b342fcb0814933b9f39c965bba504b160fdaf557c0d
5
5
  SHA512:
6
- metadata.gz: cdd30481a4d28645ef05f5d570f253d73029258581743045677e5a7e1c19a395b28082656aa44f588eac2c8be074b01a93240102143a822d166fa250325efd93
7
- data.tar.gz: 5190303791d16fb83affbed23cd01e5b20c1b28a245f94824ee9f01670ec454a9b6c9696d632cabc3ce7c0990da8d0809adb14b7c3eddeda7aa8a5ec077f7f54
6
+ metadata.gz: 4df4ece416d4c1d16729bbaaff61c0e196d3635796f1a743b754b189d91109891c38232bd484e1240fa55a28db6c85898a3b550fd3bd781337de4f05d18ecfc3
7
+ data.tar.gz: 4a523fc4d15f677b80671a3a09490e267ad1c322fcd0b45d7f2c37dd68ea54586c36efd6204e22f2d20cea15185a2d2072c53bf5f0857fa5011442198ad61431
data/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.37 (2026-11-03)
4
+
5
+ - An exception raised entirely in gems, with no frame from the app's
6
+ code, now sends its first library frames as its stack trace
7
+ (`net-smtp/lib/net/smtp.rb#start`), normalized like the top frame, with
8
+ no absolute paths or versions. Before, it sent none, so an error such as
9
+ a mailer job's `Net::ReadTimeout` showed no trace.
10
+
11
+ ## 0.1.36 (2026-11-01)
12
+
13
+ - `deployangel exercise --param id=42` fills path parameters (`:id`,
14
+ `{id}`, `<int:id>`), so routes like `GET /apps/:app_id/deployments/:id`
15
+ can be exercised. Skipped routes name the parameters they need.
16
+ - `deployangel exercise --header="Name: value"`, or
17
+ `DEPLOYANGEL_EXERCISE_HEADERS` (one per line), sends headers with every
18
+ request, such as a Bearer token or a test account's session cookie. The
19
+ output names them but never shows their values, and they go only to the
20
+ `--url` host.
21
+
3
22
  ## 0.1.35 (2026-11-01)
4
23
 
5
24
  - `deployangel exercise` prints each route's status codes (`404 ×1`)
data/README.md CHANGED
@@ -149,7 +149,8 @@ the dashboard says so.
149
149
  - Release identity, runtime versions, and a per-process instance ID.
150
150
 
151
151
  - Exceptions: a stable fingerprint, the exception class, the first line of the
152
- message, and application frames only. In the message, numbers, IDs, emails,
152
+ message, and application frames only, or the first library frames (gem
153
+ paths without versions) when none is the app's. In the message, numbers, IDs, emails,
153
154
  UUIDs, long hex strings, and quoted values are replaced with placeholders, and
154
155
  so are the request's host and, with ros-apartment, the tenants the request or
155
156
  job switched to. Other words are kept: `Payment failed for Jane Doe` is sent
@@ -238,7 +239,7 @@ end
238
239
  ### Exception messages
239
240
 
240
241
  To send exceptions without any message, only their class, fingerprint, and
241
- application frames:
242
+ stack frames:
242
243
 
243
244
  ```ruby
244
245
  DeployAngel.configure do |config|
@@ -597,8 +598,8 @@ evidence. A release deployed during an app's first 24 hours can't clear,
597
598
  so its plan lists nothing.
598
599
 
599
600
  `deployangel exercise --url=<production URL>` does the read-only part for
600
- you, from wherever it runs: it requests the plan's GET routes that have no
601
- path parameters, spreading any request shortfall across them (at most 200
601
+ you, from wherever it runs: it requests the plan's GET routes whose path
602
+ parameters it can fill, spreading any request shortfall across them (at most 200
602
603
  requests, about 5 a second, as `DeployAngel-Exercise`), then records what it
603
604
  sent on the release, where the page lists it under "Exercised from your
604
605
  side". It skips and names routes that change data or need a path
@@ -608,6 +609,19 @@ every page answers 404, 405, or 410, it records nothing and exits 5, since
608
609
  the URL is probably not the app's own host (a marketing site, say). A "CLI
609
610
  & coding agents" token can run it. `--dry-run` shows what it would send.
610
611
 
612
+ `--param id=42` fills a path parameter (`:id`, `{id}`, `<int:id>`) in every
613
+ route that has it, so `--param app_id=3 --param id=212` reaches
614
+ `GET /apps/:app_id/deployments/:id`. A value fills every route with that
615
+ name, so a wrong one just gets a 404 and no more requests.
616
+
617
+ `--header="Name: value"` sends a header with every request, such as
618
+ `Authorization: Bearer …` for an API or a test account's session cookie for
619
+ signed-in pages. Put secrets in `DEPLOYANGEL_EXERCISE_HEADERS` instead (one
620
+ header per line), which keeps them out of shell history. Headers go only to
621
+ the `--url` host, since redirects aren't followed. The output names them
622
+ but never shows their values, and they're never sent to DeployAngel. Use a
623
+ test account with as little access as possible: its cookie is a full sign-in.
624
+
611
625
  ### Suggested instructions for your coding agent
612
626
 
613
627
  `deployangel install agents` adds instructions like these. To add them
@@ -639,8 +653,9 @@ verdict: call the `wait_for_verification` MCP tool with the commit and
639
653
  If its status is "exercisable" or "waiting_for_activity", run
640
654
  `bundle exec deployangel exercise --url=<production URL>`: it sends the
641
655
  plan's read-only requests and records them on the release. Offer to
642
- exercise what it skips: routes that change data only with a test account
643
- or after asking. If the status is "warm_up", nothing run can clear it.
656
+ exercise what it skips: fill a route's IDs with `--param name=value`,
657
+ using an ID you can see in the app, and run routes that change data only
658
+ with a test account or after asking. If the status is "warm_up", nothing run can clear it.
644
659
  ```
645
660
 
646
661
  ## Development
@@ -34,7 +34,8 @@ module DeployAngel
34
34
  plan What to exercise so a release [--commit=SHA | --version=V | --deployment=ID]
35
35
  clears sooner [--format=text|json]
36
36
  exercise Send the plan's read-only --url=PRODUCTION_URL [--commit=SHA | --version=V | --deployment=ID]
37
- requests to production [--max-requests=200] [--dry-run]
37
+ requests to production [--param=NAME=VALUE] [--header="Name: value"]
38
+ [--max-requests=200] [--dry-run]
38
39
  exception Details for a fingerprint deployangel exception FINGERPRINT
39
40
  check Report a smoke test result --name=NAME --status=pass|fail [--covers=a,b]
40
41
  [--commit=SHA | --deployment=ID] [--details-url=URL]
@@ -116,8 +117,9 @@ module DeployAngel
116
117
  If its status is "exercisable" or "waiting_for_activity", run
117
118
  `bundle exec deployangel exercise --url=<production URL>`: it sends the
118
119
  plan's read-only requests and records them on the release. Offer to
119
- exercise what it skips: routes that change data only with a test account
120
- or after asking. If the status is "warm_up", nothing run can clear it.
120
+ exercise what it skips: fill a route's IDs with `--param name=value`,
121
+ using an ID you can see in the app, and run routes that change data only
122
+ with a test account or after asking. If the status is "warm_up", nothing run can clear it.
121
123
  - Confidence `medium` clears a release just as `high` does (high also needs 80%
122
124
  coverage and a full baseline), so don't call a medium clearance weak.
123
125
  Warnings (`suspicious: true`) aren't failures: report them as "watching
@@ -282,8 +284,10 @@ module DeployAngel
282
284
  # Sends the plan's read-only requests from here, then records what it
283
285
  # sent on the release (spec §16, Exercise records).
284
286
  def exercise
285
- options = parse(max_requests: Exerciser::MAX_REQUESTS, dry_run: false) do |o, opts|
287
+ options = parse(max_requests: Exerciser::MAX_REQUESTS, dry_run: false, headers: [], params: []) do |o, opts|
286
288
  o.on("--url=URL") { |v| opts[:url] = v }
289
+ o.on("--header=HEADER") { |v| opts[:headers] << v }
290
+ o.on("--param=NAME=VALUE") { |v| opts[:params] << v }
287
291
  o.on("--commit=SHA") { |v| opts[:commit] = v }
288
292
  o.on("--version=VERSION") { |v| opts[:version] = v }
289
293
  o.on("--deployment=ID") { |v| opts[:deployment_id] = v }
@@ -292,6 +296,15 @@ module DeployAngel
292
296
  end
293
297
  return usage_error("exercise needs --url, the app's production URL, like https://example.com") unless options[:url].to_s.match?(%r{\Ahttps?://[^/\s]+})
294
298
 
299
+ # DEPLOYANGEL_EXERCISE_HEADERS, one per line, keeps a secret out of
300
+ # shell history and the process list.
301
+ begin
302
+ headers = Exerciser.parse_headers(options[:headers] + @env["DEPLOYANGEL_EXERCISE_HEADERS"].to_s.split("\n"))
303
+ params = Exerciser.parse_params(options[:params])
304
+ rescue ArgumentError => e
305
+ return usage_error(e.message)
306
+ end
307
+
295
308
  target = target_from(options)
296
309
  return usage_error("no target: pass --commit, --version, or --deployment, or run inside a git repository") unless target
297
310
 
@@ -308,19 +321,22 @@ module DeployAngel
308
321
  return 0
309
322
  end
310
323
 
311
- exerciser = Exerciser.new(base_url: options[:url], max_requests: options[:max_requests].clamp(1, Exerciser::MAX_REQUESTS),
324
+ exerciser = Exerciser.new(base_url: options[:url], headers: headers, params: params, max_requests: options[:max_requests].clamp(1, Exerciser::MAX_REQUESTS),
312
325
  sleeper: @sleeper, requester: @requester)
326
+ header_names = ("With headers: #{headers.keys.join(", ")} (values not shown)" if headers.any?)
313
327
  if options[:dry_run]
314
328
  targets, skipped = exerciser.plan(plan)
315
329
  @stdout.puts("Would send #{targets.sum(&:count)} requests:", *targets.map { |t| " #{exerciser.url_for(t.path)} ×#{t.count}" })
316
- @stdout.puts("Skipped:", *skipped.map { |s| " #{s["key"]} (#{s["reason"]})" }) if skipped.any?
330
+ @stdout.puts(header_names) if header_names
331
+ print_skipped(skipped)
317
332
  return 0
318
333
  end
319
334
 
320
335
  result = exerciser.run(plan)
321
336
  @stdout.puts("Sent #{result.sent} requests to #{options[:url]}:",
322
337
  *result.routes.map { |r| " #{r["key"]} ×#{r["requests"]}: #{status_codes(result.codes[r["key"]])}" })
323
- @stdout.puts("Skipped:", *result.skipped.map { |s| " #{s["key"]} (#{s["reason"]})" }) if result.skipped.any?
338
+ @stdout.puts(header_names) if header_names
339
+ print_skipped(result.skipped)
324
340
  @stdout.puts("Stopped early: #{options[:url]} isn't answering.") if result.unreachable
325
341
  # Requests that reached no page aren't worth recording: the app
326
342
  # likely never saw them.
@@ -332,12 +348,23 @@ module DeployAngel
332
348
  return USAGE_ERROR
333
349
  end
334
350
  if result.routes.any?
335
- client.record_exercise(document.dig("deployment", "id"), routes: result.routes, skipped: result.skipped, ran_at: @clock.call.utc.iso8601)
351
+ client.record_exercise(document.dig("deployment", "id"), routes: result.routes,
352
+ skipped: result.skipped.map { |s| s.except("missing") }, ran_at: @clock.call.utc.iso8601)
336
353
  @stdout.puts("Recorded on the release. Wait for the verdict with: deployangel verify --wait")
337
354
  end
338
355
  result.unreachable ? USAGE_ERROR : 0
339
356
  end
340
357
 
358
+ # Each skipped route and why, naming the parameters a --param would fill.
359
+ def print_skipped(skipped)
360
+ return if skipped.empty?
361
+
362
+ @stdout.puts("Skipped:", *skipped.map { |s| " #{s["key"]} (#{s["reason"]}#{": #{s["missing"].join(", ")}" if s["missing"]&.any?})" })
363
+ return unless skipped.any? { |s| s["missing"]&.any? }
364
+
365
+ @stdout.puts("Fill path parameters with --param, such as --param #{skipped.find { |s| s["missing"]&.any? }["missing"].first}=42.")
366
+ end
367
+
341
368
  # "200 ×4, 500 ×1", in the order they came.
342
369
  def status_codes(codes)
343
370
  codes.map { |code, n| "#{code || "no answer"} ×#{n}" }.join(", ")
@@ -46,10 +46,14 @@ module DeployAngel
46
46
  "#{normalize_path(path, root)}##{label}"
47
47
  end
48
48
 
49
- # Application frames only, relative to the root.
49
+ # Application frames only, relative to the root. An exception raised
50
+ # entirely in gems (a mailer job's SMTP timeout) has none, so it gets its
51
+ # first frames instead, normalized as in top_frame: they're the only way
52
+ # to tell which gem's path failed.
50
53
  def backtrace(exception, root:)
51
- locations(exception).select { |path, _| app_path?(path, root) }.first(MAX_BACKTRACE)
52
- .map { |path, label| "#{normalize_path(path, root)}##{label}" }
54
+ frames = locations(exception)
55
+ app = frames.select { |path, _| app_path?(path, root) }
56
+ (app.empty? ? frames : app).first(MAX_BACKTRACE).map { |path, label| "#{normalize_path(path, root)}##{label}" }
53
57
  end
54
58
 
55
59
  # Only quoted values, numbers, emails, UUIDs, and long hex are replaced;
@@ -7,9 +7,10 @@ module DeployAngel
7
7
  # `deployangel exercise`: sends a release's exercise plan's read-only
8
8
  # requests to production from the customer's side, then records what it
9
9
  # sent, so the release page says which routes were exercised that way.
10
- # Only GET routes with no path parameters and not marked as changing data;
11
- # the rest are skipped and named, for the agent or a smoke test with a test
12
- # account. The requests count like any traffic through the app's agent.
10
+ # Only GET routes not marked as changing data, and only those whose path
11
+ # parameters the caller filled (--param id=42); the rest are skipped and
12
+ # named, for the agent or a smoke test with a test account. The requests
13
+ # count like any traffic through the app's agent.
13
14
  class Exerciser
14
15
  MAX_REQUESTS = 200
15
16
  INTERVAL = 0.2 # about 5 requests a second
@@ -19,8 +20,14 @@ module DeployAngel
19
20
  # A route table can list pages an app doesn't serve, like the edit page
20
21
  # of a resource that has none. One answer like this is enough to know.
21
22
  NOT_SERVED = [ 404, 405, 410 ].freeze
22
- # Rails' :id and *path, Django's <int:pk>, FastAPI's {id}.
23
- PARAMETER = /\/[:*{<]/
23
+ # A path parameter, capturing its name: Rails' :id and *path, FastAPI's
24
+ # and ASP.NET Core's {id}, {id:int}, and {*path}, Django's <int:pk>.
25
+ PARAMETER = /[:*]([A-Za-z_]\w*)|\{\*{0,2}([A-Za-z_]\w*)(?::[^}]*)?\??\}|<(?:\w+:)?([A-Za-z_]\w*)>/
26
+ # One that can span segments, so its value keeps its slashes.
27
+ GLOB = %r{\A(?:\*|\{\*|\{\w+:path\}|<path:)}
28
+ PARAM = /\A([A-Za-z_]\w*)=(.+)\z/
29
+ # "Name: value", as with curl -H: a header name, then a value on one line.
30
+ HEADER = /\A([!#$%&'*+.^_`|~0-9A-Za-z-]+):[ \t]*(\S[^\r\n]*)\z/
24
31
 
25
32
  Target = Struct.new(:key, :path, :count)
26
33
  # codes: each route's exact status codes, nil for no answer, for
@@ -36,8 +43,30 @@ module DeployAngel
36
43
  end
37
44
  end
38
45
 
39
- def initialize(base_url:, max_requests: MAX_REQUESTS, sleeper: ->(seconds) { sleep(seconds) }, requester: nil)
46
+ # Headers to send with every request, such as a token or a test
47
+ # account's session cookie, from "Name: value" lines. Raises
48
+ # ArgumentError naming the line, never its value.
49
+ def self.parse_headers(lines)
50
+ lines.map(&:strip).reject(&:empty?).each_with_index.to_h do |line, i|
51
+ match = HEADER.match(line) or raise ArgumentError, "header #{i + 1} must look like \"Name: value\""
52
+ [ match[1], match[2].rstrip ]
53
+ end
54
+ end
55
+
56
+ # Path parameter values, from "name=value" (--param id=42). Each fills
57
+ # that name in every route that has it.
58
+ def self.parse_params(pairs)
59
+ pairs.to_h do |pair|
60
+ match = PARAM.match(pair) or raise ArgumentError, "--param must look like name=value, such as --param id=42 (got #{pair.inspect})"
61
+ [ match[1], match[2] ]
62
+ end
63
+ end
64
+
65
+ # headers go only to base_url's host: redirects aren't followed.
66
+ def initialize(base_url:, headers: {}, params: {}, max_requests: MAX_REQUESTS, sleeper: ->(seconds) { sleep(seconds) }, requester: nil)
40
67
  @base = URI(base_url)
68
+ @headers = headers
69
+ @params = params
41
70
  @max_requests = max_requests
42
71
  @sleeper = sleeper
43
72
  @requester = requester || method(:get)
@@ -51,11 +80,12 @@ module DeployAngel
51
80
  next unless item["kind"] == "route"
52
81
 
53
82
  method, path = item["key"].to_s.split(" ", 2)
54
- reason =
55
- if item["mutating"] || method != "GET" then "changes data"
56
- elsif path.nil? || path.match?(PARAMETER) then "needs a path parameter"
57
- end
58
- next skipped << { "key" => item["key"], "reason" => reason } if reason
83
+ next skipped << { "key" => item["key"], "reason" => "changes data" } if item["mutating"] || method != "GET"
84
+
85
+ path, missing = path ? fill(path) : [ nil, [] ]
86
+ if path.nil? || missing.any?
87
+ next skipped << { "key" => item["key"], "reason" => "needs a path parameter", "missing" => missing }
88
+ end
59
89
 
60
90
  needed = item["runs_needed"] ? item["runs_needed"].to_i - item["runs"].to_i : 1
61
91
  targets << Target.new(item["key"], path, [ needed, 1 ].max)
@@ -98,6 +128,24 @@ module DeployAngel
98
128
  codes: routes.to_h { |route| [ route["key"], @codes[route["key"]].to_h ] }, unsent: unsent)
99
129
  end
100
130
 
131
+ # The path with the given parameters filled in, and the names of any
132
+ # left unfilled.
133
+ def fill(path)
134
+ missing = []
135
+ filled = path.gsub(PARAMETER) do |segment|
136
+ name = $1 || $2 || $3
137
+ value = @params[name]
138
+ unless value
139
+ missing << name
140
+ next segment
141
+ end
142
+
143
+ glob = segment.match?(GLOB)
144
+ (glob ? value.split("/", -1) : [ value ]).map { |part| URI.encode_www_form_component(part).gsub("+", "%20") }.join("/")
145
+ end
146
+ [ filled, missing.uniq ]
147
+ end
148
+
101
149
  def url_for(path)
102
150
  uri = @base.dup
103
151
  uri.path = "#{@base.path.chomp("/")}#{path}"
@@ -108,7 +156,7 @@ module DeployAngel
108
156
  private
109
157
  def send_request(target)
110
158
  @sleeper.call(INTERVAL) if @sent.positive?
111
- status = @requester.call(url_for(target.path))
159
+ status = @requester.call(url_for(target.path), @headers)
112
160
  @sent += 1
113
161
  kind = status ? "#{status / 100}xx" : "error"
114
162
  @statuses[target.key][kind] += 1
@@ -139,11 +187,12 @@ module DeployAngel
139
187
 
140
188
  # The status code, or nil when there was no answer. Redirects aren't
141
189
  # followed: the request already reached the app.
142
- def get(url)
190
+ def get(url, headers)
143
191
  uri = URI(url)
144
192
  request = Net::HTTP::Get.new(uri)
145
193
  request["User-Agent"] = "DeployAngel-Exercise/#{DeployAngel::VERSION} (+https://www.deployangel.com/docs#exercise)"
146
194
  request["Accept"] = "text/html,application/json;q=0.9,*/*;q=0.8"
195
+ headers.each { |name, value| request[name] = value }
147
196
  Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https", open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
148
197
  http.request(request).code.to_i
149
198
  end
@@ -117,7 +117,7 @@ module DeployAngel
117
117
  TARGET_PROPERTIES),
118
118
  tool("list_deployments", "Recent deployments with their verification state and verdict.",
119
119
  { "limit" => { "type" => "integer", "minimum" => 1, "maximum" => 50, "default" => 10 } }),
120
- tool("get_exception", "Sanitized details and the application stack trace for an exception fingerprint.",
120
+ tool("get_exception", "Sanitized details and a representative stack trace (application frames, or library frames when there are none) for an exception fingerprint.",
121
121
  { "fingerprint" => { "type" => "string" } }, required: %w[fingerprint]),
122
122
  tool("list_late_regressions", "Failures found after a release was cleared, on paths the clearance did not cover.",
123
123
  { "since" => { "type" => "string", "description" => "ISO 8601 time" },
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.35"
4
+ VERSION = "0.1.37"
5
5
  end
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.35
4
+ version: 0.1.37
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Owens