deployangel 0.1.0
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 +7 -0
- data/CHANGELOG.md +33 -0
- data/LICENSE.txt +21 -0
- data/README.md +293 -0
- data/exe/deployangel +6 -0
- data/lib/deployangel/agent.rb +242 -0
- data/lib/deployangel/capistrano/steps.rb +61 -0
- data/lib/deployangel/capistrano/tasks.rake +54 -0
- data/lib/deployangel/capistrano.rb +13 -0
- data/lib/deployangel/ci_environment.rb +46 -0
- data/lib/deployangel/cli/formatter.rb +96 -0
- data/lib/deployangel/cli.rb +257 -0
- data/lib/deployangel/client.rb +92 -0
- data/lib/deployangel/configuration.rb +49 -0
- data/lib/deployangel/core/aggregator.rb +181 -0
- data/lib/deployangel/core/buffer.rb +43 -0
- data/lib/deployangel/core/fingerprint.rb +91 -0
- data/lib/deployangel/core/histogram.rb +42 -0
- data/lib/deployangel/core/instance.rb +30 -0
- data/lib/deployangel/core/protocol.rb +60 -0
- data/lib/deployangel/core/release.rb +88 -0
- data/lib/deployangel/core/transport.rb +66 -0
- data/lib/deployangel/fork_hook.rb +15 -0
- data/lib/deployangel/mcp.rb +205 -0
- data/lib/deployangel/rails/active_job.rb +70 -0
- data/lib/deployangel/rails/error_subscriber.rb +19 -0
- data/lib/deployangel/rails/http.rb +65 -0
- data/lib/deployangel/rails/metadata.rb +96 -0
- data/lib/deployangel/rails/railtie.rb +35 -0
- data/lib/deployangel/sidekiq.rb +71 -0
- data/lib/deployangel/verification_waiter.rb +97 -0
- data/lib/deployangel/version.rb +5 -0
- data/lib/deployangel.rb +118 -0
- metadata +81 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: b1740b8bdbebb7cf89db6fe0a93eb1e09f3452492c88ee0d13bac1d6060e6778
|
|
4
|
+
data.tar.gz: 95bf36dc26fdf153a4783172dd3296a92e004f0109068ade65716d46ffa18590
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: fc6ba275f576aa06da811002891c7d158008fc729462c3c6cec9a30792a62f891469cffb41e8a9538cf863611d348d94c451d6735c41a9b9a0c63fb2fee692ac
|
|
7
|
+
data.tar.gz: 6a85b7eef1c4989d5dcab0790d226e21a9acaee7a5c40592d11c1608eb5f5bef06452dcd01ae41e988d91ce9a6d6a475c0d32bc74051ecf23a18d4346ea002ec
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.0 (2026-10-01)
|
|
4
|
+
|
|
5
|
+
- Release identity on Kamal (`KAMAL_VERSION`), Render (`RENDER_GIT_COMMIT`),
|
|
6
|
+
Fly.io (the deploy's image tag), Railway (`RAILWAY_GIT_COMMIT_SHA`, or the
|
|
7
|
+
deployment ID), Coolify (`SOURCE_COMMIT`), and Dokku (`GIT_REV`), with no
|
|
8
|
+
configuration. Kamal versions with
|
|
9
|
+
uncommitted changes report the version and the commit it starts with.
|
|
10
|
+
- `deployangel release` registers Kamal's release inside a Kamal hook, and
|
|
11
|
+
`deployangel install kamal` adds a `post-deploy` hook that does it.
|
|
12
|
+
|
|
13
|
+
- HTTP request telemetry for Rails: request counts, 4xx/5xx status counts,
|
|
14
|
+
unhandled exceptions, and mergeable latency histograms, per route.
|
|
15
|
+
- Release identity from configuration, Heroku dyno metadata, or a REVISION file.
|
|
16
|
+
- One payload per process per minute (heartbeats included), sent from a
|
|
17
|
+
background thread; fails open, bounded buffering, fork-safe.
|
|
18
|
+
- Background jobs: attempts, failures, discards, duration, and queue latency
|
|
19
|
+
per job class, for every ActiveJob adapter (Solid Queue, Sidekiq, GoodJob,
|
|
20
|
+
...) and for native Sidekiq jobs. Failures handled by `retry_on` and
|
|
21
|
+
`discard_on` are counted exactly once.
|
|
22
|
+
- Exceptions: stable fingerprints (algorithm v1: class plus top application
|
|
23
|
+
frame, with no line numbers, messages, or gem versions), sanitized messages,
|
|
24
|
+
and application-only representative backtraces, tagged with the route or
|
|
25
|
+
job class they came from. Handled `Rails.error` reports are included for
|
|
26
|
+
context.
|
|
27
|
+
- Application metadata, once per process: route table, job classes, Solid
|
|
28
|
+
Queue recurring schedules, critical flows, and file digests (paths and
|
|
29
|
+
short hashes only; uploaded only when the cloud has not seen the manifest).
|
|
30
|
+
- Defaults to `https://api.deployangel.com`.
|
|
31
|
+
- `deployangel` CLI (`verify`, `status`, `release`, `check`, `exception`) with
|
|
32
|
+
stable exit codes for coding agents and CI, and `deployangel mcp`, a stdio
|
|
33
|
+
MCP server.
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
The MIT License (MIT)
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Jordan Owens
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in
|
|
13
|
+
all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
THE SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
# DeployAngel for Rails
|
|
2
|
+
|
|
3
|
+
The DeployAngel agent. It watches what your Rails app does in production and
|
|
4
|
+
reports one small aggregated payload per process per minute. DeployAngel uses
|
|
5
|
+
that to verify every deployment, and to tell you (or your coding agent) when a
|
|
6
|
+
release is **cleared** and you can stop watching it.
|
|
7
|
+
|
|
8
|
+
## Install
|
|
9
|
+
|
|
10
|
+
Requires Ruby 3.1 or later and Rails 7.1 or later.
|
|
11
|
+
|
|
12
|
+
```ruby
|
|
13
|
+
# Gemfile
|
|
14
|
+
gem "deployangel"
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
Set these in production:
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
DEPLOYANGEL_TOKEN=da_live_... # an ingestion token with the telemetry scope
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
The agent must know which release it is running. It finds it in this order:
|
|
24
|
+
|
|
25
|
+
1. `DEPLOYANGEL_REVISION` (the commit SHA) and `DEPLOYANGEL_RELEASE_VERSION`, if you set them
|
|
26
|
+
2. Heroku dyno metadata. Enable it with `heroku labs:enable runtime-dyno-metadata`
|
|
27
|
+
3. Kamal: `KAMAL_VERSION`, which Kamal sets in every container
|
|
28
|
+
4. Render: `RENDER_GIT_COMMIT`
|
|
29
|
+
5. Fly.io: the deploy's image tag, from `FLY_IMAGE_REF`. Fly.io sets no commit, so
|
|
30
|
+
pass one in for commit-level change tracking:
|
|
31
|
+
`fly deploy --build-arg GIT_SHA=$(git rev-parse HEAD)`, with `ARG GIT_SHA` and
|
|
32
|
+
`ENV DEPLOYANGEL_REVISION=$GIT_SHA` in the Dockerfile
|
|
33
|
+
6. Railway: `RAILWAY_GIT_COMMIT_SHA`, or `RAILWAY_DEPLOYMENT_ID` for deploys that
|
|
34
|
+
didn't come from GitHub
|
|
35
|
+
7. Coolify: `SOURCE_COMMIT`. Dokku: `GIT_REV`
|
|
36
|
+
8. a `REVISION` file in the app root, which Capistrano writes and any build step can
|
|
37
|
+
|
|
38
|
+
For other Docker deploys (compose, Swarm, ECS, Kubernetes), bake the commit into
|
|
39
|
+
the image, since `.dockerignore` usually leaves `.git` out:
|
|
40
|
+
|
|
41
|
+
```dockerfile
|
|
42
|
+
ARG GIT_SHA
|
|
43
|
+
ENV DEPLOYANGEL_REVISION=$GIT_SHA
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
and build with `docker build --build-arg GIT_SHA=$(git rev-parse HEAD) .`. On
|
|
47
|
+
DigitalOcean App Platform, set `DEPLOYANGEL_REVISION: ${_self.COMMIT_HASH}` in
|
|
48
|
+
the app spec. If the agent finds none of these, its telemetry can't be tied to a
|
|
49
|
+
deploy, and the dashboard says so.
|
|
50
|
+
|
|
51
|
+
## What it sends
|
|
52
|
+
|
|
53
|
+
- HTTP request counts, 4xx/5xx counts by status code, unhandled exceptions,
|
|
54
|
+
and a latency histogram, both per app and per route.
|
|
55
|
+
- Routes are recorded as the matched pattern (`GET /users/:id`), never the raw
|
|
56
|
+
path. At most 100 routes are sent per payload; the rest are folded into
|
|
57
|
+
`__other__`.
|
|
58
|
+
- Background jobs: attempts, failed attempts, discarded jobs, duration, and
|
|
59
|
+
queue latency, per job class. Works with every ActiveJob adapter (Solid
|
|
60
|
+
Queue, Sidekiq, GoodJob, ...) and with native Sidekiq jobs through a server
|
|
61
|
+
middleware. Failures that `retry_on` or `discard_on` handle still count.
|
|
62
|
+
- Release identity, runtime versions, and a per-process instance ID.
|
|
63
|
+
|
|
64
|
+
- Exceptions: a stable fingerprint, the exception class, a sanitized message
|
|
65
|
+
(numbers, IDs, emails, and quoted values removed), and application frames
|
|
66
|
+
only.
|
|
67
|
+
- Once per process: the route table, job classes, Solid Queue recurring
|
|
68
|
+
schedules, critical flows, and file digests (relative paths and hashes, never
|
|
69
|
+
file contents) so DeployAngel can tell which routes changed in a release.
|
|
70
|
+
Disable digests with `DEPLOYANGEL_FILE_DIGESTS=false`.
|
|
71
|
+
|
|
72
|
+
It does not send request bodies, parameters, headers, cookies, SQL, logs, or
|
|
73
|
+
user data.
|
|
74
|
+
|
|
75
|
+
## Safety
|
|
76
|
+
|
|
77
|
+
- Nothing runs on the network during a request. Requests only update
|
|
78
|
+
in-memory counters.
|
|
79
|
+
- Payloads are sent from a background thread, once a minute, with short
|
|
80
|
+
timeouts.
|
|
81
|
+
- When DeployAngel is unreachable, the buffer is bounded (10 payloads) and the
|
|
82
|
+
oldest are dropped. Your app is never blocked or failed.
|
|
83
|
+
- Safe across forks (Puma cluster mode and similar), and the minute in progress
|
|
84
|
+
is flushed at shutdown.
|
|
85
|
+
|
|
86
|
+
## Configuration
|
|
87
|
+
|
|
88
|
+
Environment variables are enough for most apps. To override in code:
|
|
89
|
+
|
|
90
|
+
```ruby
|
|
91
|
+
# config/initializers/deployangel.rb
|
|
92
|
+
DeployAngel.configure do |config|
|
|
93
|
+
config.environments = %w[production staging] # default: production only
|
|
94
|
+
end
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
Critical flows (for example sign-up or password reset) are always listed in
|
|
98
|
+
clearance reports:
|
|
99
|
+
|
|
100
|
+
```ruby
|
|
101
|
+
DeployAngel.configure do |config|
|
|
102
|
+
config.critical_flows = { "password_reset" => [ "POST /password_resets", "job:PasswordsMailer" ] }
|
|
103
|
+
end
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
`DEPLOYANGEL_ENABLED=true|false` forces reporting on or off in any environment.
|
|
107
|
+
`DEPLOYANGEL_URL` overrides the API endpoint (default `https://api.deployangel.com`).
|
|
108
|
+
|
|
109
|
+
## Checkpoints
|
|
110
|
+
|
|
111
|
+
Errors and latency don't catch work that silently stops happening. Count the
|
|
112
|
+
business events that matter with one line:
|
|
113
|
+
|
|
114
|
+
```ruby
|
|
115
|
+
DeployAngel.checkpoint("order.created")
|
|
116
|
+
DeployAngel.checkpoint("receipt.sent")
|
|
117
|
+
DeployAngel.checkpoint("webhook.stripe.processed", count: events.size)
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
DeployAngel learns each checkpoint's normal rate relative to your traffic and
|
|
121
|
+
fails a release after which it drops sharply or stops, even when every request
|
|
122
|
+
and job still succeeds. Only drops are flagged, and a checkpoint without enough
|
|
123
|
+
traffic never blocks a release from being cleared. Checkpoints can also be part
|
|
124
|
+
of a critical flow (`checkpoint:order.created`).
|
|
125
|
+
|
|
126
|
+
It's safe to call anywhere: it never raises, never touches the network, and is
|
|
127
|
+
ignored outside reporting environments. Names use letters, numbers, and
|
|
128
|
+
`. _ : -` (up to 100 characters); keep them to a fixed set rather than
|
|
129
|
+
including IDs, since only 100 distinct names are counted per minute.
|
|
130
|
+
|
|
131
|
+
## Registering deploys
|
|
132
|
+
|
|
133
|
+
On Heroku, the add-on registers every release for you. Anywhere else,
|
|
134
|
+
DeployAngel notices a new release when the agent first reports it, and verifies
|
|
135
|
+
it from there. The releases running when you install the agent are the baseline.
|
|
136
|
+
|
|
137
|
+
Registering deploys yourself adds a link to the CI run and a label of your
|
|
138
|
+
choice, and starts verification as soon as the deploy finishes. Use an API
|
|
139
|
+
token created for **CI deploys** in the dashboard (`DEPLOYANGEL_API_TOKEN`):
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
bundle exec deployangel release --commit=$GIT_SHA [--version=LABEL]
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
`--version` is an optional label for the dashboard (a tag, build number, or
|
|
146
|
+
date). Without it, releases are labelled by their short commit.
|
|
147
|
+
|
|
148
|
+
In GitHub Actions, GitLab CI, CircleCI, and Buildkite, `deployangel release`
|
|
149
|
+
needs no arguments: it uses the CI's commit, labels the release with the build
|
|
150
|
+
number (`run-123`, `pipeline-45`, `build-67`), and links the release page back
|
|
151
|
+
to the run. Explicit options still win.
|
|
152
|
+
|
|
153
|
+
```yaml
|
|
154
|
+
# .github/workflows/deploy.yml, after the deploy step
|
|
155
|
+
- run: bundle exec deployangel release
|
|
156
|
+
env:
|
|
157
|
+
DEPLOYANGEL_API_TOKEN: ${{ secrets.DEPLOYANGEL_API_TOKEN }}
|
|
158
|
+
- run: bundle exec deployangel verify --wait --until=initial # optional: fail the job on a bad release
|
|
159
|
+
env:
|
|
160
|
+
DEPLOYANGEL_API_TOKEN: ${{ secrets.DEPLOYANGEL_API_TOKEN }}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
### Deploying with Kamal
|
|
164
|
+
|
|
165
|
+
The agent reads `KAMAL_VERSION`, so it knows its release with no setup. Pass
|
|
166
|
+
the agent's token to the app through Kamal's secrets:
|
|
167
|
+
|
|
168
|
+
```yaml
|
|
169
|
+
# config/deploy.yml
|
|
170
|
+
env:
|
|
171
|
+
secret:
|
|
172
|
+
- DEPLOYANGEL_TOKEN
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
with `DEPLOYANGEL_TOKEN=$DEPLOYANGEL_TOKEN` in `.kamal/secrets`. To register each
|
|
176
|
+
deploy, add a post-deploy hook:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
bundle exec deployangel install kamal
|
|
180
|
+
```
|
|
181
|
+
|
|
182
|
+
It writes `.kamal/hooks/post-deploy`, which runs `deployangel release` after each
|
|
183
|
+
`kamal deploy` (or adds nothing if you already have a hook, and prints the line
|
|
184
|
+
to add). Set `DEPLOYANGEL_API_TOKEN` (a CI deploys token) wherever you run
|
|
185
|
+
`kamal deploy`. The hook never fails a deploy.
|
|
186
|
+
|
|
187
|
+
### Deploying with Capistrano
|
|
188
|
+
|
|
189
|
+
Capistrano writes a `REVISION` file into each release, so the agent already
|
|
190
|
+
knows which commit it's running. Add one line to the `Capfile` to register
|
|
191
|
+
each deploy:
|
|
192
|
+
|
|
193
|
+
```ruby
|
|
194
|
+
# Capfile
|
|
195
|
+
require "deployangel/capistrano"
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
and set `DEPLOYANGEL_API_TOKEN` (a CI deploys token) wherever you run
|
|
199
|
+
`cap production deploy`. After each deploy is published, the release is
|
|
200
|
+
registered with its commit, labelled with Capistrano's release timestamp.
|
|
201
|
+
If DeployAngel can't be reached, the deploy continues with a warning.
|
|
202
|
+
|
|
203
|
+
Optional settings in `config/deploy.rb`:
|
|
204
|
+
|
|
205
|
+
```ruby
|
|
206
|
+
set :deployangel_wait, "initial" # wait for the initial check after deploying
|
|
207
|
+
set :deployangel_wait_timeout, "15m"
|
|
208
|
+
set :deployangel_version, nil # label releases by commit instead of timestamp
|
|
209
|
+
set :deployangel_register, false # turn it off, e.g. for a stage without DeployAngel
|
|
210
|
+
```
|
|
211
|
+
|
|
212
|
+
With `:deployangel_wait`, `cap` exits with an error if the release fails
|
|
213
|
+
verification, which CI can act on. It never rolls anything back.
|
|
214
|
+
|
|
215
|
+
## CLI and coding agents
|
|
216
|
+
|
|
217
|
+
The gem ships a `deployangel` command. It doesn't boot Rails. Give it a token
|
|
218
|
+
with the `verifications:read` scope (plus `deployments` to register deploys or
|
|
219
|
+
report checks). Never use the production telemetry token on a developer
|
|
220
|
+
machine.
|
|
221
|
+
|
|
222
|
+
```bash
|
|
223
|
+
export DEPLOYANGEL_API_TOKEN=da_live_...
|
|
224
|
+
|
|
225
|
+
bundle exec deployangel verify --wait # current git HEAD, until a verdict
|
|
226
|
+
bundle exec deployangel verify --wait --until=initial # return at the 15-minute initial check
|
|
227
|
+
bundle exec deployangel status # latest deployment
|
|
228
|
+
bundle exec deployangel release --commit=$SHA # register a deploy (manual or CI)
|
|
229
|
+
bundle exec deployangel check --name="smoke: signup" --status=pass --covers=registration
|
|
230
|
+
bundle exec deployangel exception <fingerprint>
|
|
231
|
+
```
|
|
232
|
+
|
|
233
|
+
Output is text on a terminal and JSON (the verdict document) when piped, or
|
|
234
|
+
choose with `--format=text|json`. Exit codes:
|
|
235
|
+
|
|
236
|
+
| Code | Meaning |
|
|
237
|
+
|---|---|
|
|
238
|
+
| 0 | verified (cleared) |
|
|
239
|
+
| 1 | failed |
|
|
240
|
+
| 2 | inconclusive (not verified) |
|
|
241
|
+
| 3 | still in progress, or timed out |
|
|
242
|
+
| 4 | deployment not found |
|
|
243
|
+
| 5 | usage, authentication, or network error |
|
|
244
|
+
| 6 | initial check: no problems so far, **not cleared** |
|
|
245
|
+
| 7 | initial check: warnings, **not cleared** |
|
|
246
|
+
|
|
247
|
+
### MCP server
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
claude mcp add deployangel -- bundle exec deployangel mcp
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
Tools: `get_verification`, `wait_for_verification` (up to 5 minutes per call),
|
|
254
|
+
`list_deployments`, `get_exception`, `list_late_regressions`, and
|
|
255
|
+
`register_deployment` when the token allows it. The tools are read-only with
|
|
256
|
+
respect to production.
|
|
257
|
+
|
|
258
|
+
### Suggested instructions for your coding agent
|
|
259
|
+
|
|
260
|
+
Add this to your `CLAUDE.md` or `AGENTS.md`:
|
|
261
|
+
|
|
262
|
+
```markdown
|
|
263
|
+
## Production verification
|
|
264
|
+
|
|
265
|
+
After deploying, run `bundle exec deployangel verify --wait --until initial`
|
|
266
|
+
(or call the `wait_for_verification` MCP tool with `until: "initial"`).
|
|
267
|
+
|
|
268
|
+
- Exit 0 / verified: the release is cleared. Report the clearance line and
|
|
269
|
+
anything DeployAngel is still watching, then move on.
|
|
270
|
+
- Exit 6: no problems so far, but NOT cleared. Report "no problems so far, not
|
|
271
|
+
yet cleared" and the expected clearance time. DeployAngel keeps verifying and
|
|
272
|
+
alerts on failure.
|
|
273
|
+
- Exit 7: warnings at the initial check. Report them and review the findings.
|
|
274
|
+
The release is NOT cleared.
|
|
275
|
+
- Exit 2 / inconclusive: the release is NOT verified. Do not claim success.
|
|
276
|
+
- Exit 1 / failed: read the findings and exceptions, investigate the likely
|
|
277
|
+
cause, and propose a fix. Do not roll back or change production without
|
|
278
|
+
explicit approval.
|
|
279
|
+
- Exit 3: still verifying; run the command again.
|
|
280
|
+
```
|
|
281
|
+
|
|
282
|
+
## Development
|
|
283
|
+
|
|
284
|
+
```bash
|
|
285
|
+
bundle install
|
|
286
|
+
bundle exec rspec
|
|
287
|
+
```
|
|
288
|
+
|
|
289
|
+
The agent speaks DeployAngel Agent Protocol v1: one gzipped JSON payload per
|
|
290
|
+
process per minute to `POST /api/v1/telemetry`, and the application's metadata
|
|
291
|
+
once per process to `POST /api/v1/application_metadata`.
|
|
292
|
+
`lib/deployangel/core/protocol.rb` and `lib/deployangel/rails/metadata.rb`
|
|
293
|
+
build them.
|
data/exe/deployangel
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeployAngel
|
|
4
|
+
# The per-process runtime: records requests into the aggregator and sends
|
|
5
|
+
# completed minutes from a background thread. Every public method fails
|
|
6
|
+
# open; nothing here may raise into, or block, the customer's app.
|
|
7
|
+
class Agent
|
|
8
|
+
TELEMETRY_PATH = "/api/v1/telemetry"
|
|
9
|
+
METADATA_PATH = "/api/v1/application_metadata"
|
|
10
|
+
SHUTDOWN_TIMEOUT = 2
|
|
11
|
+
DEFAULT_PAUSE = 60
|
|
12
|
+
|
|
13
|
+
attr_reader :config, :release, :instance
|
|
14
|
+
attr_writer :metadata
|
|
15
|
+
|
|
16
|
+
def initialize(config:, environment:, root: nil, framework: nil, framework_version: nil,
|
|
17
|
+
env: ENV, transport: nil, clock: -> { Time.now.utc }, eager: false)
|
|
18
|
+
@config = config
|
|
19
|
+
@active = config.active?(environment)
|
|
20
|
+
@release = Release.resolve(config: config, env: env, root: root)
|
|
21
|
+
@runtime = Protocol.runtime(framework: framework, framework_version: framework_version)
|
|
22
|
+
@transport = transport || Transport.new(config)
|
|
23
|
+
@env = env
|
|
24
|
+
@root = root
|
|
25
|
+
@clock = clock
|
|
26
|
+
@eager = eager
|
|
27
|
+
@thread_mutex = Mutex.new
|
|
28
|
+
@warned = {}
|
|
29
|
+
reset_process_state
|
|
30
|
+
warn_once(:unknown_release, "DeployAngel could not determine the release; set DEPLOYANGEL_REVISION " \
|
|
31
|
+
"or enable Heroku dyno metadata. Telemetry will not be attributed to deployments.") if @active && @release.unknown?
|
|
32
|
+
start_reporter if @active && eager
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def active?
|
|
36
|
+
@active
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def record_request(route_key:, status:, duration_ms:, unhandled: false)
|
|
40
|
+
return unless @active
|
|
41
|
+
|
|
42
|
+
after_fork! if Process.pid != @pid
|
|
43
|
+
start_reporter
|
|
44
|
+
@aggregator.record(route_key: route_key, status: status, duration_ms: duration_ms, unhandled: unhandled)
|
|
45
|
+
rescue StandardError => e
|
|
46
|
+
warn_once(:record, "DeployAngel failed to record a request: #{e.class}: #{e.message}")
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def record_job(job_class:, duration_ms:, failed: false, discarded: false, queue_latency_ms: nil)
|
|
50
|
+
return unless @active
|
|
51
|
+
|
|
52
|
+
after_fork! if Process.pid != @pid
|
|
53
|
+
start_reporter
|
|
54
|
+
@aggregator.record_job(job_class: job_class, duration_ms: duration_ms, failed: failed,
|
|
55
|
+
discarded: discarded, queue_latency_ms: queue_latency_ms)
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
warn_once(:record_job, "DeployAngel failed to record a job: #{e.class}: #{e.message}")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def record_discard(job_class:)
|
|
61
|
+
return unless @active
|
|
62
|
+
|
|
63
|
+
@aggregator.record_discard(job_class: job_class)
|
|
64
|
+
rescue StandardError => e
|
|
65
|
+
warn_once(:record_discard, "DeployAngel failed to record a discarded job: #{e.class}: #{e.message}")
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def record_checkpoint(name, count: 1)
|
|
69
|
+
return unless @active
|
|
70
|
+
|
|
71
|
+
after_fork! if Process.pid != @pid
|
|
72
|
+
start_reporter
|
|
73
|
+
@aggregator.record_checkpoint(name: name, count: count)
|
|
74
|
+
rescue StandardError => e
|
|
75
|
+
warn_once(:record_checkpoint, "DeployAngel failed to record a checkpoint: #{e.class}: #{e.message}")
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
SEEN = :@__deployangel_recorded
|
|
79
|
+
|
|
80
|
+
# Records each exception object once, whichever instrumentation sees it
|
|
81
|
+
# first (middleware, job events, or Rails.error).
|
|
82
|
+
def record_exception(exception, source: nil, handled: false)
|
|
83
|
+
return unless @active && exception.is_a?(Exception)
|
|
84
|
+
return if exception.instance_variable_defined?(SEEN)
|
|
85
|
+
|
|
86
|
+
exception.instance_variable_set(SEEN, true) unless exception.frozen?
|
|
87
|
+
start_reporter
|
|
88
|
+
@aggregator.record_exception(Fingerprint.for(exception, root: @root), source: source, handled: handled,
|
|
89
|
+
backtrace: Fingerprint.backtrace(exception, root: @root))
|
|
90
|
+
rescue StandardError => e
|
|
91
|
+
warn_once(:record_exception, "DeployAngel failed to record an exception: #{e.class}: #{e.message}")
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
# Signals this process can observe, announced in every payload so the
|
|
95
|
+
# cloud never claims to verify what the agent cannot see.
|
|
96
|
+
def capabilities
|
|
97
|
+
DeployAngel.capabilities
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# Drains completed minutes into the buffer and sends what it can.
|
|
101
|
+
def flush(include_current: false)
|
|
102
|
+
return 0 unless @active
|
|
103
|
+
|
|
104
|
+
@aggregator.drain(include_current: include_current, max_periods: config.max_queued_payloads).each do |period|
|
|
105
|
+
@buffer.push(Protocol.telemetry(period, instance: @instance, release: @release, runtime: @runtime,
|
|
106
|
+
capabilities: capabilities))
|
|
107
|
+
end
|
|
108
|
+
send_buffered
|
|
109
|
+
rescue StandardError => e
|
|
110
|
+
warn_once(:flush, "DeployAngel failed to flush telemetry: #{e.class}: #{e.message}")
|
|
111
|
+
0
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def start_reporter
|
|
115
|
+
return unless @active
|
|
116
|
+
return if @thread&.alive?
|
|
117
|
+
|
|
118
|
+
@thread_mutex.synchronize do
|
|
119
|
+
return if @thread&.alive?
|
|
120
|
+
|
|
121
|
+
@stopping = false
|
|
122
|
+
@thread = Thread.new { run_reporter }
|
|
123
|
+
@thread.name = "deployangel-reporter"
|
|
124
|
+
@thread.report_on_exception = false
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Sends the in-progress minute too, bounded by a short timeout, because
|
|
129
|
+
# deployments restart processes.
|
|
130
|
+
def shutdown(timeout: SHUTDOWN_TIMEOUT)
|
|
131
|
+
return unless @active
|
|
132
|
+
|
|
133
|
+
@stopping = true
|
|
134
|
+
@thread&.wakeup if @thread&.alive?
|
|
135
|
+
finisher = Thread.new { flush(include_current: true) }
|
|
136
|
+
finisher.join(timeout)
|
|
137
|
+
rescue StandardError
|
|
138
|
+
nil
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
# Threads do not survive fork, and the parent's identity and counts must
|
|
142
|
+
# not be reused by the child.
|
|
143
|
+
def after_fork!
|
|
144
|
+
reset_process_state
|
|
145
|
+
start_reporter if @active && @eager
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# Sent once per process; retried on the next minute if it fails. File
|
|
149
|
+
# digests are only uploaded when the cloud has not seen the manifest.
|
|
150
|
+
def send_metadata
|
|
151
|
+
return if @metadata_sent || @metadata.nil? || paused?
|
|
152
|
+
|
|
153
|
+
base = { "protocol_version" => Protocol::VERSION, "instance" => @instance.to_protocol,
|
|
154
|
+
"release" => @release.to_protocol, "runtime" => @runtime }.merge(@metadata.to_protocol)
|
|
155
|
+
result = @transport.post(METADATA_PATH, base)
|
|
156
|
+
return unless result.ok?
|
|
157
|
+
|
|
158
|
+
if result.body.is_a?(Hash) && result.body["manifest_needed"]
|
|
159
|
+
return unless @transport.post(METADATA_PATH, base.merge("files" => @metadata.files)).ok?
|
|
160
|
+
end
|
|
161
|
+
@metadata_sent = true
|
|
162
|
+
rescue StandardError => e
|
|
163
|
+
warn_once(:metadata, "DeployAngel failed to send application metadata: #{e.class}: #{e.message}")
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
def reset_process_state
|
|
168
|
+
@pid = Process.pid
|
|
169
|
+
@instance = Instance.new(env: @env, now: @clock.call)
|
|
170
|
+
@aggregator = Aggregator.new(max_routes: config.max_routes, clock: @clock)
|
|
171
|
+
@buffer = Buffer.new(config.max_queued_payloads)
|
|
172
|
+
@paused_until = nil
|
|
173
|
+
@metadata_sent = false
|
|
174
|
+
@thread = nil
|
|
175
|
+
# Spread processes across the first seconds of each minute.
|
|
176
|
+
@jitter = rand(1.0..10.0)
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def run_reporter
|
|
180
|
+
until @stopping
|
|
181
|
+
sleep(seconds_until_next_flush)
|
|
182
|
+
break if @stopping
|
|
183
|
+
|
|
184
|
+
send_metadata
|
|
185
|
+
flush
|
|
186
|
+
end
|
|
187
|
+
rescue StandardError => e
|
|
188
|
+
warn_once(:reporter, "DeployAngel reporter stopped: #{e.class}: #{e.message}")
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
def seconds_until_next_flush
|
|
192
|
+
now = @clock.call.to_f
|
|
193
|
+
interval = config.flush_interval
|
|
194
|
+
(interval - (now % interval)) + @jitter
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
def send_buffered
|
|
198
|
+
sent = 0
|
|
199
|
+
while (payload = @buffer.shift)
|
|
200
|
+
if paused?
|
|
201
|
+
@buffer.unshift(payload)
|
|
202
|
+
break
|
|
203
|
+
end
|
|
204
|
+
|
|
205
|
+
result = @transport.post(TELEMETRY_PATH, payload)
|
|
206
|
+
case result.outcome
|
|
207
|
+
when :ok
|
|
208
|
+
sent += 1
|
|
209
|
+
when :retry
|
|
210
|
+
@buffer.unshift(payload)
|
|
211
|
+
break
|
|
212
|
+
else
|
|
213
|
+
handle_rejection(result)
|
|
214
|
+
end
|
|
215
|
+
end
|
|
216
|
+
sent
|
|
217
|
+
end
|
|
218
|
+
|
|
219
|
+
def handle_rejection(result)
|
|
220
|
+
case result.status
|
|
221
|
+
when 429
|
|
222
|
+
@paused_until = @clock.call + (result.retry_after.to_i.positive? ? result.retry_after : DEFAULT_PAUSE)
|
|
223
|
+
when 401, 403
|
|
224
|
+
warn_once(:auth, "DeployAngel rejected the token (HTTP #{result.status}); check DEPLOYANGEL_TOKEN " \
|
|
225
|
+
"and that it has the telemetry scope.")
|
|
226
|
+
else
|
|
227
|
+
warn_once(:"rejected_#{result.status}", "DeployAngel rejected a telemetry payload (HTTP #{result.status}).")
|
|
228
|
+
end
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
def paused?
|
|
232
|
+
@paused_until && @clock.call < @paused_until
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def warn_once(key, message)
|
|
236
|
+
return if @warned[key]
|
|
237
|
+
|
|
238
|
+
@warned[key] = true
|
|
239
|
+
config.logger&.warn(message)
|
|
240
|
+
end
|
|
241
|
+
end
|
|
242
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "stringio"
|
|
4
|
+
require_relative "../cli"
|
|
5
|
+
|
|
6
|
+
module DeployAngel
|
|
7
|
+
module Capistrano
|
|
8
|
+
# What the Capistrano tasks do, kept free of Capistrano so it can be
|
|
9
|
+
# tested on its own. Each step runs the CLI in-process and returns
|
|
10
|
+
# [outcome, message], where outcome is :ok, :warn, or :fail.
|
|
11
|
+
module Steps
|
|
12
|
+
# Exit codes that let a deploy continue for each --until mode.
|
|
13
|
+
PASSING = { "initial" => [ 0, 6 ], "verdict" => [ 0 ], "closed" => [ 0 ] }.freeze
|
|
14
|
+
# Not a pass, but not a reason to fail the deploy either: warnings at
|
|
15
|
+
# the initial check, not cleared, or still in progress.
|
|
16
|
+
WARNING = [ 2, 3, 7 ].freeze
|
|
17
|
+
|
|
18
|
+
module_function
|
|
19
|
+
|
|
20
|
+
# Registration never fails a deploy: DeployAngel being unreachable or
|
|
21
|
+
# misconfigured shouldn't stop a release from shipping.
|
|
22
|
+
def register(token:, commit:, version: nil, endpoint: nil, output: StringIO.new, **cli)
|
|
23
|
+
return [ :warn, "DeployAngel: DEPLOYANGEL_API_TOKEN is not set; release not registered" ] if blank?(token)
|
|
24
|
+
return [ :warn, "DeployAngel: no commit for this release; not registered" ] if blank?(commit)
|
|
25
|
+
|
|
26
|
+
argv = [ "release", "--commit=#{commit}", "--provider=capistrano" ]
|
|
27
|
+
argv << "--version=#{version}" unless blank?(version)
|
|
28
|
+
code = run(argv, token: token, endpoint: endpoint, output: output, **cli)
|
|
29
|
+
code.zero? ? [ :ok, output.string.strip ] : [ :warn, "DeployAngel: could not register the release (exit #{code}): #{output.string.strip}" ]
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Waits for the release's verification. Fails the deploy only when
|
|
33
|
+
# DeployAngel found a problem (exit 1).
|
|
34
|
+
def verify(token:, commit:, until_mode:, timeout:, endpoint: nil, output: StringIO.new, **cli)
|
|
35
|
+
return [ :warn, "DeployAngel: DEPLOYANGEL_API_TOKEN is not set; not waiting for a verdict" ] if blank?(token)
|
|
36
|
+
|
|
37
|
+
argv = [ "verify", "--commit=#{commit}", "--wait", "--until=#{until_mode}", "--timeout=#{timeout}", "--format=text" ]
|
|
38
|
+
code = run(argv, token: token, endpoint: endpoint, output: output, **cli)
|
|
39
|
+
summary = output.string.strip
|
|
40
|
+
if PASSING.fetch(until_mode, [ 0 ]).include?(code)
|
|
41
|
+
[ :ok, summary ]
|
|
42
|
+
elsif WARNING.include?(code) || code != 1
|
|
43
|
+
[ :warn, "DeployAngel: #{summary}" ]
|
|
44
|
+
else
|
|
45
|
+
[ :fail, "DeployAngel: release failed verification\n#{summary}" ]
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
# cli: options passed through to DeployAngel::CLI (tests inject a client).
|
|
50
|
+
def run(argv, token:, endpoint:, output:, **cli)
|
|
51
|
+
env = { "DEPLOYANGEL_API_TOKEN" => token }
|
|
52
|
+
env["DEPLOYANGEL_URL"] = endpoint unless blank?(endpoint)
|
|
53
|
+
DeployAngel::CLI.new(argv, env: env, stdout: output, stderr: output, **cli).run
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def blank?(value)
|
|
57
|
+
value.to_s.strip.empty?
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|