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 +4 -4
- data/CHANGELOG.md +16 -0
- data/README.md +6 -1
- data/lib/deployangel/agent.rb +4 -2
- data/lib/deployangel/cli/formatter.rb +24 -5
- data/lib/deployangel/core/aggregator.rb +21 -5
- data/lib/deployangel/core/execution_context.rb +37 -0
- data/lib/deployangel/core/protocol.rb +5 -1
- data/lib/deployangel/mcp.rb +4 -2
- data/lib/deployangel/rack/http.rb +5 -0
- data/lib/deployangel/rails/active_job.rb +29 -0
- data/lib/deployangel/sidekiq.rb +3 -0
- data/lib/deployangel/version.rb +1 -1
- data/lib/deployangel.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: fae02d0fd8337fc6eb82ef0492d1bab86d03071dd9c9774dc332e95a6f52ffc9
|
|
4
|
+
data.tar.gz: 733fce2015cfa6e8fd6f5c618005cdda6c5aabf6aae8d7be128a156c46be65a6
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
data/lib/deployangel/agent.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
lines
|
|
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"])
|
|
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 =
|
|
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]
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
data/lib/deployangel/mcp.rb
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
|
data/lib/deployangel/sidekiq.rb
CHANGED
|
@@ -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
|
data/lib/deployangel/version.rb
CHANGED
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.
|
|
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
|