deployangel 0.1.14 → 0.1.16
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 +26 -0
- data/README.md +37 -10
- data/lib/deployangel/agent.rb +47 -7
- data/lib/deployangel/cli.rb +79 -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/core/release.rb +80 -1
- data/lib/deployangel/metadata.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: 6080cb323e886841312b4bab49edd0962e2c5a4379c3f23d6dc7eb738944104d
|
|
4
|
+
data.tar.gz: 36c6a15cfa2c70c3c92e58a500537bed8da5a19291af4852d530e857f21181fa
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: c586644c984d299e424cc0b3042ac5a474c3271de24ca4ee5f0f89103fb574c0253bc362efa7d0b1d8b212b83ef4aa5a2fcfafb2f3c77477282d2a8f52b284e7
|
|
7
|
+
data.tar.gz: ed91b7685224b5e613d3cc7de6cf28b36096563d5195e7e4b41069dcd691d6bb7ce49369a8f77fc5c519197bfe1984a4b0f9982520c7a6cb71efe094a69b0150
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.16 (2026-10-08)
|
|
4
|
+
|
|
5
|
+
- The agent finds the release in a git checkout when nothing earlier names
|
|
6
|
+
it: the commit `HEAD` names, read from `.git` in the app root or up to 3
|
|
7
|
+
directories above it (worktrees and submodules included), without running
|
|
8
|
+
`git` (source `git_head`). It comes after a `REVISION` file and before ECS.
|
|
9
|
+
- When nothing else identifies the release, the agent reports a fingerprint
|
|
10
|
+
of the app's code as the version (`code:` and 12 hex characters, source
|
|
11
|
+
`code_fingerprint`): the hash of the file digests it already computes. It's
|
|
12
|
+
worked out in the reporter thread as soon as it starts, never at boot or
|
|
13
|
+
in a request, and needs file digests on. A warning suggests setting
|
|
14
|
+
`DEPLOYANGEL_REVISION`: without a commit, DeployAngel shows which files
|
|
15
|
+
changed but not the commits and pull requests.
|
|
16
|
+
- `deployangel install docker` adds `ARG GIT_SHA` and
|
|
17
|
+
`ENV DEPLOYANGEL_REVISION=$GIT_SHA` at the end of the Dockerfile's last
|
|
18
|
+
stage, before its `CMD`, and says which `--build-arg` to pass. It never
|
|
19
|
+
edits CI workflows.
|
|
20
|
+
|
|
21
|
+
## 0.1.15 (2026-10-06)
|
|
22
|
+
|
|
23
|
+
- Checkpoints report whether they were recorded in an HTTP request or a job
|
|
24
|
+
(`http` and `job` counts on each checkpoint), so DeployAngel compares a
|
|
25
|
+
checkpoint recorded in jobs against job traffic rather than requests. A job
|
|
26
|
+
performed inline during a request counts as a job. Needs a DeployAngel
|
|
27
|
+
server that reads the new fields; older servers ignore them.
|
|
28
|
+
|
|
3
29
|
## 0.1.14 (2026-10-06)
|
|
4
30
|
|
|
5
31
|
- `deployangel plan` separates what clearance waits on ("Needed to clear")
|
data/README.md
CHANGED
|
@@ -36,26 +36,46 @@ The agent must know which release it is running. It finds it in this order:
|
|
|
36
36
|
didn't come from GitHub
|
|
37
37
|
7. Coolify: `SOURCE_COMMIT`. Dokku: `GIT_REV`
|
|
38
38
|
8. a `REVISION` file in the app root, which Capistrano writes and any build step can
|
|
39
|
-
9.
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
39
|
+
9. a git checkout, for servers deployed by `git pull`, Fabric, or Ansible: the commit
|
|
40
|
+
`HEAD` names, read from `.git` in the app root or up to 3 directories above it
|
|
41
|
+
(worktrees and submodules included). The agent reads the files; it never runs `git`
|
|
42
|
+
10. ECS, including Fargate: the container's image, from the metadata endpoint ECS
|
|
43
|
+
provides (one local request at boot). The image tag is the release when it's a
|
|
44
|
+
commit or a version like `v1.4.2`; with a moving tag like `latest`, the image
|
|
45
|
+
digest is
|
|
46
|
+
11. a fingerprint of the app's code, reported as `code:` and 12 hex characters: the
|
|
47
|
+
hash of the [file digests](#what-it-sends) the agent already computes, so the
|
|
48
|
+
same code is the same release. It needs file digests on, and is worked out in
|
|
49
|
+
the background when the reporter starts, not at boot. Without a commit, the
|
|
50
|
+
dashboard shows which files changed but not the commits and pull requests, so
|
|
51
|
+
the agent logs a warning suggesting `DEPLOYANGEL_REVISION`
|
|
43
52
|
|
|
44
53
|
For other Docker deploys (compose, Swarm, Kubernetes), and on ECS to see each
|
|
45
54
|
release by its commit, bake the commit into the image, since `.dockerignore`
|
|
46
55
|
usually leaves `.git` out:
|
|
47
56
|
|
|
57
|
+
```bash
|
|
58
|
+
bundle exec deployangel install docker
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
adds this to the end of the Dockerfile's last stage, before its `CMD` (so the
|
|
62
|
+
layers above it stay cached):
|
|
63
|
+
|
|
48
64
|
```dockerfile
|
|
49
65
|
ARG GIT_SHA
|
|
50
66
|
ENV DEPLOYANGEL_REVISION=$GIT_SHA
|
|
51
67
|
```
|
|
52
68
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
69
|
+
Then build with `docker build --build-arg GIT_SHA=$(git rev-parse HEAD) .`
|
|
70
|
+
(`fly deploy --build-arg GIT_SHA=$(git rev-parse HEAD)` on Fly.io, or
|
|
71
|
+
`build-args: GIT_SHA=${{ github.sha }}` with GitHub's docker/build-push-action).
|
|
72
|
+
It changes nothing if the Dockerfile already sets `DEPLOYANGEL_REVISION`, and
|
|
73
|
+
never edits CI workflows. A build without the argument leaves the variable
|
|
74
|
+
empty, which counts as unset. On DigitalOcean App Platform, set
|
|
75
|
+
`DEPLOYANGEL_REVISION: ${_self.COMMIT_HASH}` in the app spec. On Elastic
|
|
76
|
+
Beanstalk, see [Deploying to Elastic Beanstalk](#deploying-to-elastic-beanstalk).
|
|
77
|
+
If the agent finds none of these, its telemetry can't be tied to a deploy, and
|
|
78
|
+
the dashboard says so.
|
|
59
79
|
|
|
60
80
|
## What it sends
|
|
61
81
|
|
|
@@ -207,6 +227,10 @@ and job still succeeds. Only drops are flagged, and a checkpoint without enough
|
|
|
207
227
|
traffic never blocks a release from being cleared. Checkpoints can also be part
|
|
208
228
|
of a critical flow (`checkpoint:order.created`).
|
|
209
229
|
|
|
230
|
+
The agent also records whether each checkpoint was counted during an HTTP
|
|
231
|
+
request or a background job, so DeployAngel compares it against the right
|
|
232
|
+
traffic: a checkpoint counted in jobs is judged against jobs, not requests.
|
|
233
|
+
|
|
210
234
|
It's safe to call anywhere: it never raises, never touches the network, and is
|
|
211
235
|
ignored outside reporting environments. Names use letters, numbers, and
|
|
212
236
|
`. _ : -` (up to 100 characters); keep them to a fixed set rather than
|
|
@@ -294,6 +318,9 @@ It writes `.kamal/hooks/post-deploy`, which runs `deployangel release` after eac
|
|
|
294
318
|
to add). Set `DEPLOYANGEL_API_TOKEN` (a CI deploys token) wherever you run
|
|
295
319
|
`kamal deploy`. The hook never fails a deploy.
|
|
296
320
|
|
|
321
|
+
Kamal apps don't need `deployangel install docker`: `KAMAL_VERSION` already
|
|
322
|
+
identifies the release.
|
|
323
|
+
|
|
297
324
|
### Deploying with Capistrano
|
|
298
325
|
|
|
299
326
|
Capistrano writes a `REVISION` file into each release, so the agent already
|
data/lib/deployangel/agent.rb
CHANGED
|
@@ -16,7 +16,7 @@ module DeployAngel
|
|
|
16
16
|
# later still lands in time.
|
|
17
17
|
FLUSH_JITTER = 1.0..50.0
|
|
18
18
|
|
|
19
|
-
attr_reader :config, :
|
|
19
|
+
attr_reader :config, :instance
|
|
20
20
|
attr_writer :metadata
|
|
21
21
|
|
|
22
22
|
def initialize(config:, environment:, root: nil, framework: nil, framework_version: nil,
|
|
@@ -24,6 +24,11 @@ module DeployAngel
|
|
|
24
24
|
@config = config
|
|
25
25
|
@active = config.active?(environment)
|
|
26
26
|
@release = Release.resolve(config: config, env: env, root: root)
|
|
27
|
+
# Nothing named the release, so the code fingerprint may. It hashes the
|
|
28
|
+
# app's files, so the reporter computes it before its first send (see
|
|
29
|
+
# #release), never at boot or in a request.
|
|
30
|
+
@fingerprint_pending = @release.unknown?
|
|
31
|
+
@release_mutex = Mutex.new
|
|
27
32
|
@runtime = Protocol.runtime(framework: framework, framework_version: framework_version)
|
|
28
33
|
@transport = transport || Transport.new(config)
|
|
29
34
|
@env = env
|
|
@@ -33,8 +38,6 @@ module DeployAngel
|
|
|
33
38
|
@thread_mutex = Mutex.new
|
|
34
39
|
@warned = {}
|
|
35
40
|
reset_process_state
|
|
36
|
-
warn_once(:unknown_release, "DeployAngel could not determine the release; set DEPLOYANGEL_REVISION " \
|
|
37
|
-
"or enable Heroku dyno metadata. Telemetry will not be attributed to deployments.") if @active && @release.unknown?
|
|
38
41
|
start_reporter if @active && eager
|
|
39
42
|
end
|
|
40
43
|
|
|
@@ -42,6 +45,25 @@ module DeployAngel
|
|
|
42
45
|
@active
|
|
43
46
|
end
|
|
44
47
|
|
|
48
|
+
# The release this process is running. The first call may build the file
|
|
49
|
+
# digests for the code fingerprint, so only the reporter thread and
|
|
50
|
+
# shutdown call it; requests and jobs never read the release.
|
|
51
|
+
def release
|
|
52
|
+
return @release unless @fingerprint_pending
|
|
53
|
+
|
|
54
|
+
@release_mutex.synchronize do
|
|
55
|
+
if @fingerprint_pending
|
|
56
|
+
@release = Release.code_fingerprint(@metadata&.file_manifest) || @release
|
|
57
|
+
@fingerprint_pending = false
|
|
58
|
+
warn_about_release
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
@release
|
|
62
|
+
rescue StandardError
|
|
63
|
+
@fingerprint_pending = false
|
|
64
|
+
@release
|
|
65
|
+
end
|
|
66
|
+
|
|
45
67
|
def record_request(route_key:, status:, duration_ms:, unhandled: false, in_totals: true)
|
|
46
68
|
return unless @active
|
|
47
69
|
|
|
@@ -71,12 +93,14 @@ module DeployAngel
|
|
|
71
93
|
warn_once(:record_discard, "DeployAngel failed to record a discarded job: #{e.class}: #{e.message}")
|
|
72
94
|
end
|
|
73
95
|
|
|
74
|
-
|
|
96
|
+
# context defaults to the unit of work this fiber is running: an HTTP
|
|
97
|
+
# request, a job, or neither.
|
|
98
|
+
def record_checkpoint(name, count: 1, context: ExecutionContext.current)
|
|
75
99
|
return unless @active
|
|
76
100
|
|
|
77
101
|
after_fork! if Process.pid != @pid
|
|
78
102
|
start_reporter
|
|
79
|
-
@aggregator.record_checkpoint(name: name, count: count)
|
|
103
|
+
@aggregator.record_checkpoint(name: name, count: count, context: context)
|
|
80
104
|
rescue StandardError => e
|
|
81
105
|
warn_once(:record_checkpoint, "DeployAngel failed to record a checkpoint: #{e.class}: #{e.message}")
|
|
82
106
|
end
|
|
@@ -111,7 +135,7 @@ module DeployAngel
|
|
|
111
135
|
return 0 unless @active
|
|
112
136
|
|
|
113
137
|
@aggregator.drain(include_current: include_current, max_periods: config.max_queued_payloads).each do |period|
|
|
114
|
-
@buffer.push(Transport.encode(Protocol.telemetry(period, instance: @instance, release:
|
|
138
|
+
@buffer.push(Transport.encode(Protocol.telemetry(period, instance: @instance, release: release,
|
|
115
139
|
runtime: @runtime, capabilities: capabilities)))
|
|
116
140
|
end
|
|
117
141
|
send_buffered
|
|
@@ -160,7 +184,7 @@ module DeployAngel
|
|
|
160
184
|
return if @metadata_sent || @metadata.nil? || paused?
|
|
161
185
|
|
|
162
186
|
base = { "protocol_version" => Protocol::VERSION, "instance" => @instance.to_protocol,
|
|
163
|
-
"release" =>
|
|
187
|
+
"release" => release.to_protocol, "runtime" => @runtime }.merge(@metadata.to_protocol)
|
|
164
188
|
result = @transport.post(METADATA_PATH, base)
|
|
165
189
|
return unless result.ok?
|
|
166
190
|
|
|
@@ -185,6 +209,10 @@ module DeployAngel
|
|
|
185
209
|
end
|
|
186
210
|
|
|
187
211
|
def run_reporter
|
|
212
|
+
# Off the request path, so a process that exits within a minute or two
|
|
213
|
+
# doesn't build the fingerprint at shutdown instead. Only once the
|
|
214
|
+
# framework has attached the metadata it's built from.
|
|
215
|
+
release if @metadata
|
|
188
216
|
until @stopping
|
|
189
217
|
sleep(seconds_until_next_flush)
|
|
190
218
|
break if @stopping
|
|
@@ -240,6 +268,18 @@ module DeployAngel
|
|
|
240
268
|
@paused_until && @clock.call < @paused_until
|
|
241
269
|
end
|
|
242
270
|
|
|
271
|
+
def warn_about_release
|
|
272
|
+
return unless @active
|
|
273
|
+
|
|
274
|
+
if @release.unknown?
|
|
275
|
+
warn_once(:unknown_release, "DeployAngel could not determine the release; set DEPLOYANGEL_REVISION " \
|
|
276
|
+
"or enable Heroku dyno metadata. Telemetry will not be attributed to deployments.")
|
|
277
|
+
elsif @release.source == "code_fingerprint"
|
|
278
|
+
warn_once(:code_fingerprint, "DeployAngel identifies releases by a fingerprint of the app's code. " \
|
|
279
|
+
"Set DEPLOYANGEL_REVISION to the deployed commit to see each release's commits and pull requests.")
|
|
280
|
+
end
|
|
281
|
+
end
|
|
282
|
+
|
|
243
283
|
def warn_once(key, message)
|
|
244
284
|
return if @warned[key]
|
|
245
285
|
|
data/lib/deployangel/cli.rb
CHANGED
|
@@ -33,7 +33,8 @@ module DeployAngel
|
|
|
33
33
|
exception Details for a fingerprint deployangel exception FINGERPRINT
|
|
34
34
|
check Report a smoke test result --name=NAME --status=pass|fail [--covers=a,b]
|
|
35
35
|
[--commit=SHA | --deployment=ID] [--details-url=URL]
|
|
36
|
-
install Add a deploy hook
|
|
36
|
+
install Add a Kamal deploy hook, or deployangel install kamal
|
|
37
|
+
bake the commit into an image deployangel install docker
|
|
37
38
|
mcp Run the MCP server over stdio (for coding agents)
|
|
38
39
|
|
|
39
40
|
With no target, verify, plan, and check use the current git HEAD commit.
|
|
@@ -57,6 +58,25 @@ module DeployAngel
|
|
|
57
58
|
bundle exec deployangel release || true
|
|
58
59
|
SH
|
|
59
60
|
|
|
61
|
+
# Goes at the end of the Dockerfile's last stage: a value that changes
|
|
62
|
+
# with every commit invalidates the cache of every layer after it.
|
|
63
|
+
DOCKER_BUILD_HELP = <<~HELP.chomp
|
|
64
|
+
Pass the commit when you build the image:
|
|
65
|
+
|
|
66
|
+
docker build --build-arg GIT_SHA=$(git rev-parse HEAD) .
|
|
67
|
+
fly deploy --build-arg GIT_SHA=$(git rev-parse HEAD)
|
|
68
|
+
GitHub Actions (docker/build-push-action):
|
|
69
|
+
build-args: GIT_SHA=${{ github.sha }}
|
|
70
|
+
|
|
71
|
+
Kamal apps don't need this: DeployAngel reads Kamal's KAMAL_VERSION.
|
|
72
|
+
HELP
|
|
73
|
+
|
|
74
|
+
DOCKERFILE_LINES = <<~DOCKERFILE
|
|
75
|
+
# The commit this image runs, for DeployAngel. Build with --build-arg GIT_SHA=$(git rev-parse HEAD).
|
|
76
|
+
ARG GIT_SHA
|
|
77
|
+
ENV DEPLOYANGEL_REVISION=$GIT_SHA
|
|
78
|
+
DOCKERFILE
|
|
79
|
+
|
|
60
80
|
def initialize(argv, env: ENV, stdin: $stdin, stdout: $stdout, stderr: $stderr, client: nil,
|
|
61
81
|
sleeper: ->(seconds) { sleep(seconds) }, clock: -> { Time.now }, git_head: nil, root: Dir.pwd)
|
|
62
82
|
@root = root
|
|
@@ -206,12 +226,17 @@ module DeployAngel
|
|
|
206
226
|
0
|
|
207
227
|
end
|
|
208
228
|
|
|
209
|
-
# Writes .kamal/hooks/post-deploy, or says what to add to a hook that
|
|
210
|
-
# already exists, rather than overwriting it.
|
|
211
229
|
def install
|
|
212
|
-
target = @argv.shift
|
|
213
|
-
|
|
230
|
+
case (target = @argv.shift)
|
|
231
|
+
when "kamal" then install_kamal
|
|
232
|
+
when "docker" then install_docker
|
|
233
|
+
else usage_error("install needs a target: deployangel install kamal, or deployangel install docker")
|
|
234
|
+
end
|
|
235
|
+
end
|
|
214
236
|
|
|
237
|
+
# Writes .kamal/hooks/post-deploy, or says what to add to a hook that
|
|
238
|
+
# already exists, rather than overwriting it.
|
|
239
|
+
def install_kamal
|
|
215
240
|
relative = File.join(".kamal", "hooks", "post-deploy")
|
|
216
241
|
path = File.join(@root, relative)
|
|
217
242
|
if File.exist?(path)
|
|
@@ -231,6 +256,55 @@ module DeployAngel
|
|
|
231
256
|
0
|
|
232
257
|
end
|
|
233
258
|
|
|
259
|
+
# Adds DEPLOYANGEL_REVISION, from a GIT_SHA build arg, to the Dockerfile.
|
|
260
|
+
# It never edits CI workflows; it says what to pass instead.
|
|
261
|
+
def install_docker
|
|
262
|
+
path = File.join(@root, "Dockerfile")
|
|
263
|
+
unless File.file?(path)
|
|
264
|
+
@stderr.puts("deployangel: no Dockerfile in #{@root}; run this where your Dockerfile is")
|
|
265
|
+
return USAGE_ERROR
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
content = File.read(path)
|
|
269
|
+
if content.include?("DEPLOYANGEL_REVISION")
|
|
270
|
+
@stdout.puts("Dockerfile already sets DEPLOYANGEL_REVISION.")
|
|
271
|
+
return 0
|
|
272
|
+
end
|
|
273
|
+
|
|
274
|
+
File.write(path, with_revision(content))
|
|
275
|
+
@stdout.puts("Added DEPLOYANGEL_REVISION to the Dockerfile's last stage. #{DOCKER_BUILD_HELP}")
|
|
276
|
+
0
|
|
277
|
+
end
|
|
278
|
+
|
|
279
|
+
# Before the CMD and ENTRYPOINT lines that end the last stage, with any
|
|
280
|
+
# comment just above them, or at the end if the stage doesn't end with
|
|
281
|
+
# one. Continuation lines aren't instructions.
|
|
282
|
+
def with_revision(content)
|
|
283
|
+
lines = content.lines
|
|
284
|
+
instructions = []
|
|
285
|
+
continuing = false
|
|
286
|
+
lines.each_with_index do |line, index|
|
|
287
|
+
text = line.strip
|
|
288
|
+
next if text.empty? || text.start_with?("#")
|
|
289
|
+
|
|
290
|
+
instructions << [ index, text[/\A\w+/].to_s.upcase ] unless continuing
|
|
291
|
+
continuing = text.end_with?("\\")
|
|
292
|
+
end
|
|
293
|
+
|
|
294
|
+
stage = instructions.drop(instructions.rindex { |_, word| word == "FROM" } || 0)
|
|
295
|
+
trailing = stage.reverse.take_while { |_, word| %w[CMD ENTRYPOINT].include?(word) }
|
|
296
|
+
if trailing.empty?
|
|
297
|
+
lines[-1] = "#{lines[-1].chomp}\n" if lines.any?
|
|
298
|
+
lines << "\n" unless lines.empty? || lines[-1].strip.empty?
|
|
299
|
+
lines << DOCKERFILE_LINES
|
|
300
|
+
else
|
|
301
|
+
at = trailing.last.first
|
|
302
|
+
at -= 1 while at.positive? && lines[at - 1].strip.start_with?("#")
|
|
303
|
+
lines.insert(at, DOCKERFILE_LINES)
|
|
304
|
+
end
|
|
305
|
+
lines.join
|
|
306
|
+
end
|
|
307
|
+
|
|
234
308
|
def mcp
|
|
235
309
|
require_relative "mcp"
|
|
236
310
|
MCP::Server.new(client: client, input: @stdin, output: @stdout, error_output: @stderr,
|
|
@@ -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
|
|
|
@@ -13,9 +13,15 @@ module DeployAngel
|
|
|
13
13
|
# Tags that move from build to build, so they can't identify a release.
|
|
14
14
|
MOVING_TAGS = %w[latest main master production prod staging stable release].freeze
|
|
15
15
|
|
|
16
|
+
# Look for .git this many directories above the app's root, for an app
|
|
17
|
+
# that lives in a subdirectory of its repository.
|
|
18
|
+
GIT_PARENT_LEVELS = 3
|
|
19
|
+
|
|
16
20
|
# Order: explicit configuration, Heroku dyno metadata, the hosting
|
|
17
21
|
# platform's own variables (Kamal, Render, Fly.io, Railway, Coolify, and
|
|
18
|
-
# Dokku's GIT_REV), a REVISION file, then ECS container
|
|
22
|
+
# Dokku's GIT_REV), a REVISION file, a git checkout, then ECS container
|
|
23
|
+
# metadata. When none of them names the release, the agent falls back to
|
|
24
|
+
# a fingerprint of the app's code (code_fingerprint), computed later.
|
|
19
25
|
def self.resolve(config:, env: ENV, root: nil, http: method(:fetch_metadata))
|
|
20
26
|
if present?(config.release_version) || present?(config.revision)
|
|
21
27
|
build(config.release_version, config.revision, "config")
|
|
@@ -36,6 +42,8 @@ module DeployAngel
|
|
|
36
42
|
build(nil, env["GIT_REV"], "git_rev")
|
|
37
43
|
elsif root && File.file?(revision_path = File.join(root, "REVISION"))
|
|
38
44
|
build(nil, File.read(revision_path, 100), "revision_file")
|
|
45
|
+
elsif root && (commit = git_head(root))
|
|
46
|
+
build(nil, commit, "git_head")
|
|
39
47
|
elsif present?(env["ECS_CONTAINER_METADATA_URI_V4"]) && (release = ecs(http.call(env["ECS_CONTAINER_METADATA_URI_V4"])))
|
|
40
48
|
release
|
|
41
49
|
else
|
|
@@ -73,6 +81,77 @@ module DeployAngel
|
|
|
73
81
|
tag unless tag.empty?
|
|
74
82
|
end
|
|
75
83
|
|
|
84
|
+
# A server deployed by `git pull` (or Fabric, or Ansible) runs from a
|
|
85
|
+
# checkout, where HEAD names the commit. Read from the repository's
|
|
86
|
+
# files, never by running git. Nil if anything is missing or unreadable.
|
|
87
|
+
def self.git_head(root)
|
|
88
|
+
git_dir = find_git_dir(File.expand_path(root)) or return
|
|
89
|
+
common_dir = git_dir
|
|
90
|
+
commondir = File.join(git_dir, "commondir")
|
|
91
|
+
common_dir = File.expand_path(File.read(commondir, 1024).strip, git_dir) if File.file?(commondir)
|
|
92
|
+
|
|
93
|
+
head = File.read(File.join(git_dir, "HEAD"), 200).to_s.strip
|
|
94
|
+
ref = head.delete_prefix("ref:").strip if head.start_with?("ref:")
|
|
95
|
+
commit = ref ? read_ref(ref, git_dir, common_dir) : head
|
|
96
|
+
commit = commit.to_s.strip.downcase
|
|
97
|
+
commit if commit.match?(COMMIT_FORMAT)
|
|
98
|
+
rescue StandardError
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# .git is a directory, or for a worktree or submodule, a file naming the
|
|
103
|
+
# real one ("gitdir: ../.git/worktrees/shop").
|
|
104
|
+
def self.find_git_dir(dir)
|
|
105
|
+
(GIT_PARENT_LEVELS + 1).times do
|
|
106
|
+
git = File.join(dir, ".git")
|
|
107
|
+
return File.directory?(git) ? git : linked_git_dir(git) if File.exist?(git)
|
|
108
|
+
|
|
109
|
+
parent = File.dirname(dir)
|
|
110
|
+
return if parent == dir
|
|
111
|
+
|
|
112
|
+
dir = parent
|
|
113
|
+
end
|
|
114
|
+
nil
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def self.linked_git_dir(file)
|
|
118
|
+
path = File.read(file, 1024).to_s[/\Agitdir:\s*(.+)$/, 1]&.strip
|
|
119
|
+
File.expand_path(path, File.dirname(file)) if present?(path)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# A branch's commit is in its ref file, in the worktree's own git dir or
|
|
123
|
+
# the common one, or else in packed-refs. Refs come from a file on disk,
|
|
124
|
+
# so anything outside refs/ is ignored rather than read.
|
|
125
|
+
def self.read_ref(ref, git_dir, common_dir)
|
|
126
|
+
return unless ref.start_with?("refs/") && !ref.include?("..")
|
|
127
|
+
|
|
128
|
+
[ git_dir, common_dir ].uniq.each do |dir|
|
|
129
|
+
path = File.join(dir, ref)
|
|
130
|
+
return File.read(path, 100) if File.file?(path)
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
packed = File.join(common_dir, "packed-refs")
|
|
134
|
+
return unless File.file?(packed)
|
|
135
|
+
|
|
136
|
+
File.foreach(packed) do |line|
|
|
137
|
+
next if line.start_with?("#", "^")
|
|
138
|
+
|
|
139
|
+
commit, name = line.split(" ", 2)
|
|
140
|
+
return commit if name.to_s.strip == ref
|
|
141
|
+
end
|
|
142
|
+
nil
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
# The release of an app that names it no other way: the hash of the file
|
|
146
|
+
# digests the agent already sends (Metadata#file_manifest), so the same
|
|
147
|
+
# code gives the same release. None when digests are off or truncated.
|
|
148
|
+
def self.code_fingerprint(manifest)
|
|
149
|
+
return unless manifest.is_a?(Hash) && !manifest["truncated"] && manifest["count"].to_i.positive?
|
|
150
|
+
|
|
151
|
+
hash = manifest["hash"].to_s
|
|
152
|
+
build("code:#{hash[0, 12]}", nil, "code_fingerprint") if hash.match?(/\A\h{12}/)
|
|
153
|
+
end
|
|
154
|
+
|
|
76
155
|
# ECS (Fargate, and EC2 with a recent agent) serves each container's
|
|
77
156
|
# metadata at ECS_CONTAINER_METADATA_URI_V4. The image tag identifies the
|
|
78
157
|
# release, and is the commit when it looks like one. A moving tag such
|
data/lib/deployangel/metadata.rb
CHANGED
|
@@ -167,6 +167,8 @@ module DeployAngel
|
|
|
167
167
|
end
|
|
168
168
|
end
|
|
169
169
|
|
|
170
|
+
# Built once per process, by the reporter: the metadata send and the
|
|
171
|
+
# code fingerprint (Release.code_fingerprint) both read it.
|
|
170
172
|
def file_manifest
|
|
171
173
|
@file_manifest ||= begin
|
|
172
174
|
if @config.file_digests
|
|
@@ -180,9 +182,9 @@ module DeployAngel
|
|
|
180
182
|
else
|
|
181
183
|
{ "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
|
|
182
184
|
end
|
|
185
|
+
rescue StandardError
|
|
186
|
+
{ "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
|
|
183
187
|
end
|
|
184
|
-
rescue StandardError
|
|
185
|
-
{ "hash" => nil, "count" => 0, "truncated" => false, "files" => {} }
|
|
186
188
|
end
|
|
187
189
|
end
|
|
188
190
|
end
|
|
@@ -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.16
|
|
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
|