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 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,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require_relative "../lib/deployangel/cli"
5
+
6
+ exit DeployAngel::CLI.new(ARGV).run
@@ -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