deployangel 0.1.13 → 0.1.15

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: 7d88c16de71d5010f445a966a6eea5701d96cc84aa3017df5fc362152331dd4e
4
- data.tar.gz: a6df4ed365874fefe0708bf3cc5e482074b7f1f148f5bfe0aa25a7de2f44ed58
3
+ metadata.gz: fae02d0fd8337fc6eb82ef0492d1bab86d03071dd9c9774dc332e95a6f52ffc9
4
+ data.tar.gz: 733fce2015cfa6e8fd6f5c618005cdda6c5aabf6aae8d7be128a156c46be65a6
5
5
  SHA512:
6
- metadata.gz: 2486f4bf45d20d65bc01bd3f89881e5def3ea9cbef692c4a87e0af631bccbb7ece176ad30c41040c8b57ac46b8635d8cc8d8625ac92eb503e0531086f4caefee
7
- data.tar.gz: 19a7a768889a541fc9f7688e3999947440ade5f747cfe6d8726211eb9536f9700369855896e31d8d0fe4091c35ee2559381d9329f78dd303be21e1a87964ddba
6
+ metadata.gz: db2bd88f8bb149df827631c8a44453c8a328c790b6174a370d39469c1e025055bfb5923c70f52984b8a1a6f2e702e79a17c600f176375dc053f31c390f7ebae8
7
+ data.tar.gz: 2e33938b4d09aa6bd342300e2097ad7a10fe8a3a722296acd5e06a6e5fc9f39a9c6f16364907abb159dcfe02c95f659e0df5096e3c51f9334240b1cce50f0faa
data/CHANGELOG.md CHANGED
@@ -1,5 +1,21 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.15 (2026-10-06)
4
+
5
+ - Checkpoints report whether they were recorded in an HTTP request or a job
6
+ (`http` and `job` counts on each checkpoint), so DeployAngel compares a
7
+ checkpoint recorded in jobs against job traffic rather than requests. A job
8
+ performed inline during a request counts as a job. Needs a DeployAngel
9
+ server that reads the new fields; older servers ignore them.
10
+
11
+ ## 0.1.14 (2026-10-06)
12
+
13
+ - `deployangel plan` separates what clearance waits on ("Needed to clear")
14
+ from changed and rarely used paths that are only worth running, and
15
+ `deployangel verify` and the GitHub Actions job summary list only what's
16
+ needed. The MCP tool `get_exercise_plan` describes the server's new
17
+ `needed` flag on each item.
18
+
3
19
  ## 0.1.13 (2026-10-06)
4
20
 
5
21
  - The MCP tools `list_deployments` and `list_late_regressions` return their
data/README.md CHANGED
@@ -207,6 +207,10 @@ and job still succeeds. Only drops are flagged, and a checkpoint without enough
207
207
  traffic never blocks a release from being cleared. Checkpoints can also be part
208
208
  of a critical flow (`checkpoint:order.created`).
209
209
 
210
+ The agent also records whether each checkpoint was counted during an HTTP
211
+ request or a background job, so DeployAngel compares it against the right
212
+ traffic: a checkpoint counted in jobs is judged against jobs, not requests.
213
+
210
214
  It's safe to call anywhere: it never raises, never touches the network, and is
211
215
  ignored outside reporting environments. Names use letters, numbers, and
212
216
  `. _ : -` (up to 100 characters); keep them to a fixed set rather than
@@ -413,7 +417,8 @@ A quiet app can take hours to clear a release. `deployangel plan` (or the
413
417
  clearance, in the numbers of the rule it's judged by, and what to exercise
414
418
  against production so it clears sooner: normally active routes short of
415
419
  their runs, routes this release changed that haven't run, and critical
416
- flows. Requests to them count like any traffic. Routes that change data
420
+ flows. It separates what clearance waits on from changed and rarely used
421
+ paths that are only worth running. Requests to them count like any traffic. Routes that change data
417
422
  are marked; use a test account for them, or ask first. Report what you
418
423
  ran with the `deployangel check` command the plan gives you. A passing
419
424
  check labels what it covered; only the requests themselves count as
@@ -71,12 +71,14 @@ module DeployAngel
71
71
  warn_once(:record_discard, "DeployAngel failed to record a discarded job: #{e.class}: #{e.message}")
72
72
  end
73
73
 
74
- def record_checkpoint(name, count: 1)
74
+ # context defaults to the unit of work this fiber is running: an HTTP
75
+ # request, a job, or neither.
76
+ def record_checkpoint(name, count: 1, context: ExecutionContext.current)
75
77
  return unless @active
76
78
 
77
79
  after_fork! if Process.pid != @pid
78
80
  start_reporter
79
- @aggregator.record_checkpoint(name: name, count: count)
81
+ @aggregator.record_checkpoint(name: name, count: count, context: context)
80
82
  rescue StandardError => e
81
83
  warn_once(:record_checkpoint, "DeployAngel failed to record a checkpoint: #{e.class}: #{e.message}")
82
84
  end
@@ -122,18 +122,37 @@ module DeployAngel
122
122
  shortfall.each { |line| lines << " #{line}" }
123
123
  end
124
124
  items = Array(plan["items"])
125
- if items.any?
126
- lines << (EXERCISABLE.include?(plan["status"]) ? "Exercise against production:" : "Optional:")
127
- items.each { |item| lines << " #{item_line(item)}" }
128
- lines << "Use a test account, or ask first, for routes marked [changes data]." if items.any? { |item| item["mutating"] }
125
+ if EXERCISABLE.include?(plan["status"])
126
+ needed, extra = items.partition { |item| needed?(item) }
127
+ item_group(lines, "Needed to clear, exercise against production:", needed)
128
+ item_group(lines, "Also worth running, not needed to clear (changed or rarely used, watched on first use):", extra)
129
+ else
130
+ item_group(lines, "Optional:", items)
129
131
  end
132
+ lines << "Use a test account, or ask first, for routes marked [changes data]." if items.any? { |item| item["mutating"] }
130
133
  lines << "Then report it: #{plan["report_with"]}" if plan["report_with"]
131
134
  lines.join("\n")
132
135
  end
133
136
 
137
+ # Only what clearance waits on: the rest is in `deployangel plan`.
134
138
  def exercisable_items(document)
135
139
  plan = document["exercise_plan"] || {}
136
- EXERCISABLE.include?(plan["status"]) ? Array(plan["items"]).map { |item| item_line(item) } : []
140
+ return [] unless EXERCISABLE.include?(plan["status"])
141
+
142
+ Array(plan["items"]).select { |item| needed?(item) }.map { |item| item_line(item) }
143
+ end
144
+
145
+ # Whether clearance waits on the item. Servers older than the needed
146
+ # flag listed normally active items first, so count those.
147
+ def needed?(item)
148
+ item.key?("needed") ? item["needed"] : item["reason"] == "normally_active"
149
+ end
150
+
151
+ def item_group(lines, heading, items)
152
+ return if items.empty?
153
+
154
+ lines << heading
155
+ items.each { |item| lines << " #{item_line(item)}" }
137
156
  end
138
157
 
139
158
  def shortfall_lines(shortfall)
@@ -37,6 +37,20 @@ module DeployAngel
37
37
  end
38
38
  end
39
39
 
40
+ # count: every recording; http and job: those recorded while handling an
41
+ # HTTP request or running a job (see ExecutionContext). The rest were
42
+ # recorded outside either.
43
+ CheckpointStats = Struct.new(:count, :http, :job) do
44
+ def record(count, context)
45
+ self.count += count
46
+ if context == ExecutionContext::HTTP
47
+ self.http += count
48
+ elsif context == ExecutionContext::JOB
49
+ self.job += count
50
+ end
51
+ end
52
+ end
53
+
40
54
  class Period
41
55
  attr_reader :started_at, :requests, :status_counts, :unhandled_exceptions, :histogram, :routes,
42
56
  :jobs, :job_classes, :exceptions, :exceptions_truncated, :checkpoints
@@ -52,13 +66,13 @@ module DeployAngel
52
66
  @job_classes = {}
53
67
  @exceptions = {}
54
68
  @exceptions_truncated = 0
55
- @checkpoints = Hash.new(0)
69
+ @checkpoints = {}
56
70
  end
57
71
 
58
72
  # Up to 100 names per period; the rest are counted together.
59
- def record_checkpoint(name, count)
73
+ def record_checkpoint(name, count, context)
60
74
  key = @checkpoints.key?(name) || @checkpoints.size < MAX_CHECKPOINTS - 1 ? name : OTHER_ROUTE
61
- @checkpoints[key] += count
75
+ (@checkpoints[key] ||= CheckpointStats.new(0, 0, 0)).record(count, context)
62
76
  end
63
77
 
64
78
  # Up to 20 fingerprints per period; the rest are only counted.
@@ -141,9 +155,11 @@ module DeployAngel
141
155
  @mutex.synchronize { (@periods[started_at] ||= Period.new(started_at)).record_discard(job_class.to_s, @max_routes) }
142
156
  end
143
157
 
144
- def record_checkpoint(name:, count: 1)
158
+ # context: where it was recorded, ExecutionContext::HTTP, ExecutionContext::JOB,
159
+ # or nil for neither.
160
+ def record_checkpoint(name:, count: 1, context: nil)
145
161
  started_at = period_start(@clock.call)
146
- @mutex.synchronize { (@periods[started_at] ||= Period.new(started_at)).record_checkpoint(name, count) }
162
+ @mutex.synchronize { (@periods[started_at] ||= Period.new(started_at)).record_checkpoint(name, count, context) }
147
163
  end
148
164
 
149
165
  # A representative backtrace is kept only the first time this process
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeployAngel
4
+ # The unit of work the current fiber is running: HTTP while the request
5
+ # middleware handles a request, JOB while job instrumentation runs a job,
6
+ # and nil otherwise (console, rake tasks, boot, a thread of the app's own).
7
+ # Checkpoints read it to say where they were recorded.
8
+ #
9
+ # Thread.current[] is fiber-local, like Rails' isolated execution state, so
10
+ # requests served concurrently in threads (Puma) or fibers (Falcon) each
11
+ # see their own. Every integration restores the previous value when its
12
+ # unit ends, even when it raises, so the innermost unit wins when they nest
13
+ # (a job performed inline during a request), and nothing leaks into the
14
+ # next request a reused thread serves.
15
+ module ExecutionContext
16
+ KEY = :__deployangel_execution_context
17
+ HTTP = :http
18
+ JOB = :job
19
+
20
+ module_function
21
+
22
+ def current
23
+ Thread.current[KEY]
24
+ end
25
+
26
+ # Returns the previous context, to hand back to restore.
27
+ def enter(kind)
28
+ previous = Thread.current[KEY]
29
+ Thread.current[KEY] = kind
30
+ previous
31
+ end
32
+
33
+ def restore(previous)
34
+ Thread.current[KEY] = previous
35
+ end
36
+ end
37
+ end
@@ -34,7 +34,11 @@ module DeployAngel
34
34
  "exceptions_truncated" => period.exceptions_truncated,
35
35
  "jobs" => job_stats(period.jobs),
36
36
  "job_classes" => period.job_classes.map { |key, stats| { "key" => key }.merge(job_stats(stats)) },
37
- "checkpoints" => period.checkpoints.map { |key, count| { "key" => key, "count" => count } }
37
+ # http and job are always sent, 0 when none, so the cloud can tell
38
+ # this agent from one that doesn't say where checkpoints happened.
39
+ "checkpoints" => period.checkpoints.map do |key, stats|
40
+ { "key" => key, "count" => stats.count, "http" => stats.http, "job" => stats.job }
41
+ end
38
42
  }
39
43
  end
40
44
 
@@ -22,7 +22,8 @@ module DeployAngel
22
22
  Findings are deterministic evidence; any "investigation" field is AI inference. These tools cannot
23
23
  change production. Do not roll back or change production without explicit approval.
24
24
  If a release isn't cleared yet, get_exercise_plan says what to exercise against production so it
25
- clears sooner. Only act on status "exercisable" or "waiting_for_activity"; for "warm_up" or
25
+ clears sooner. Items with needed true are what clearance waits on; the rest are only worth running.
26
+ Only act on status "exercisable" or "waiting_for_activity"; for "warm_up" or
26
27
  "no_baseline" nothing you run can clear it. Exercise routes marked mutating only with a test account
27
28
  or after asking. Report what you ran with the plan's report_with command (deployangel check).
28
29
  TEXT
@@ -102,7 +103,8 @@ module DeployAngel
102
103
  )),
103
104
  tool("get_exercise_plan", "What stands between a release and clearance, and what to exercise against " \
104
105
  "production so it clears sooner: normally active routes short of their runs, routes this release changed " \
105
- "that haven't run, and critical flows. Routes marked mutating change data: use a test account or ask first. " \
106
+ "that haven't run, and critical flows. needed marks what clearance waits on; the rest are only worth running. " \
107
+ "Routes marked mutating change data: use a test account or ask first. " \
106
108
  "Your requests count as ordinary traffic; report what you ran with report_with. Defaults to the current git HEAD.",
107
109
  TARGET_PROPERTIES),
108
110
  tool("list_deployments", "Recent deployments with their verification state and verdict.",
@@ -31,11 +31,16 @@ module DeployAngel
31
31
 
32
32
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
33
  Redaction.request_host = env["HTTP_HOST"]
34
+ # Checkpoints recorded from here until the app returns count as
35
+ # recorded in a request.
36
+ previous_context = ExecutionContext.enter(ExecutionContext::HTTP)
34
37
  begin
35
38
  status, headers, body = @app.call(env)
36
39
  rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
37
40
  record(env, 500, started, unhandled: true, exception: e)
38
41
  raise
42
+ ensure
43
+ ExecutionContext.restore(previous_context)
39
44
  end
40
45
  # A framework that renders an exception itself swallows it before it
41
46
  # reaches this middleware, and renders some as 4xx; only those that
@@ -11,6 +11,34 @@ module DeployAngel
11
11
  # perform finishes.
12
12
  module ActiveJob
13
13
  STATE = :@__deployangel_job_state
14
+ PREVIOUS_CONTEXT = :@__deployangel_previous_context
15
+
16
+ # Marks the fiber as running a job for as long as perform.active_job
17
+ # lasts, so checkpoints recorded by the job say so. ActiveSupport calls
18
+ # finish from an ensure, so the previous context (an HTTP request, for
19
+ # a job performed inline) comes back even when the job raises. It is
20
+ # kept on the job, so only a perform that started here restores.
21
+ module ContextListener
22
+ module_function
23
+
24
+ def start(_name, _id, payload)
25
+ job = payload[:job]
26
+ return unless job && DeployAngel.recording?
27
+
28
+ job.instance_variable_set(PREVIOUS_CONTEXT, ExecutionContext.enter(ExecutionContext::JOB))
29
+ rescue StandardError
30
+ nil
31
+ end
32
+
33
+ def finish(_name, _id, payload)
34
+ job = payload[:job]
35
+ return unless job&.instance_variable_defined?(PREVIOUS_CONTEXT)
36
+
37
+ ExecutionContext.restore(job.remove_instance_variable(PREVIOUS_CONTEXT))
38
+ rescue StandardError
39
+ nil
40
+ end
41
+ end
14
42
 
15
43
  module_function
16
44
 
@@ -23,6 +51,7 @@ module DeployAngel
23
51
  subscribe("enqueue_retry.active_job") { |payload| state(payload).merge!(failed: true, error: payload[:error]) }
24
52
  subscribe("discard.active_job") { |payload| state(payload).merge!(failed: true, discarded: true, error: payload[:error]) }
25
53
  subscribe("retry_stopped.active_job") { |payload| state(payload).merge!(failed: true, discarded: true, error: payload[:error]) }
54
+ ::ActiveSupport::Notifications.subscribe("perform.active_job", ContextListener)
26
55
  ::ActiveSupport::Notifications.subscribe("perform.active_job") { |event| record(event) }
27
56
  end
28
57
 
@@ -16,12 +16,15 @@ module DeployAngel
16
16
 
17
17
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
18
18
  latency = DeployAngel::Sidekiq.queue_latency_ms(job)
19
+ previous_context = ExecutionContext.enter(ExecutionContext::JOB)
19
20
  begin
20
21
  yield
21
22
  rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
22
23
  DeployAngel.record_exception(e, source: "job_class:#{job["class"]}")
23
24
  record(job, started, latency, failed: true)
24
25
  raise
26
+ ensure
27
+ ExecutionContext.restore(previous_context)
25
28
  end
26
29
  record(job, started, latency, failed: false)
27
30
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.13"
4
+ VERSION = "0.1.15"
5
5
  end
data/lib/deployangel.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "deployangel/core/release"
9
9
  require_relative "deployangel/core/instance"
10
10
  require_relative "deployangel/core/aggregator"
11
11
  require_relative "deployangel/core/protocol"
12
+ require_relative "deployangel/core/execution_context"
12
13
  require_relative "deployangel/core/buffer"
13
14
  require_relative "deployangel/core/transport"
14
15
  require_relative "deployangel/agent"
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.13
4
+ version: 0.1.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Owens
@@ -38,6 +38,7 @@ files:
38
38
  - lib/deployangel/configuration.rb
39
39
  - lib/deployangel/core/aggregator.rb
40
40
  - lib/deployangel/core/buffer.rb
41
+ - lib/deployangel/core/execution_context.rb
41
42
  - lib/deployangel/core/fingerprint.rb
42
43
  - lib/deployangel/core/histogram.rb
43
44
  - lib/deployangel/core/instance.rb