deployangel 0.1.28 → 0.1.30
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 +35 -0
- data/README.md +66 -6
- data/lib/deployangel/agent.rb +36 -1
- data/lib/deployangel/core/aggregator.rb +19 -2
- data/lib/deployangel/core/protocol.rb +6 -2
- data/lib/deployangel/core/running_jobs.rb +44 -0
- data/lib/deployangel/hanami/http.rb +37 -0
- data/lib/deployangel/hanami/metadata.rb +131 -0
- data/lib/deployangel/hanami/resolver.rb +36 -0
- data/lib/deployangel/hanami.rb +71 -0
- data/lib/deployangel/metadata.rb +15 -0
- data/lib/deployangel/queue_backlog.rb +55 -0
- data/lib/deployangel/rack/http.rb +21 -3
- data/lib/deployangel/rails/active_job.rb +8 -4
- data/lib/deployangel/rails/railtie.rb +1 -0
- data/lib/deployangel/sidekiq.rb +3 -0
- data/lib/deployangel/sinatra/http.rb +40 -0
- data/lib/deployangel/sinatra/metadata.rb +48 -0
- data/lib/deployangel/sinatra.rb +77 -0
- data/lib/deployangel/version.rb +1 -1
- data/lib/deployangel.rb +10 -0
- metadata +12 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: cb73e91bc627176c422a77379e49c662588f866524d591d367f4e6e44368ee57
|
|
4
|
+
data.tar.gz: ffff6fa5fe0ce44ba2f17fb58e689eb541c5ad04549bb684ffa17d6995bc3bc4
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: e4d40abb95f27a7b2ee692016943edb4f5970edbb57aa85bd75ce66946438604863cc8ae8c8bb89d4d75f01aa8697a2cd9d496ca1393cae39b58dd4d9f9255a7
|
|
7
|
+
data.tar.gz: 4bccc9b61268107aa9948087d4b42caee50998e08060b47157f2551a0f37f8050676dac8634e5961df06e8ce5cfff8792a6287ed1b1be5d51f4e75ba169fdc92
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,40 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.30 (2026-10-16)
|
|
4
|
+
|
|
5
|
+
- Hanami 2.2 or later: `require "deployangel/hanami"` and
|
|
6
|
+
`DeployAngel::Hanami.install(self)` in the app's class. Requests are
|
|
7
|
+
recorded by the route pattern they matched, scopes' and slices' prefixes
|
|
8
|
+
included, through a router resolver that builds on any the app configured;
|
|
9
|
+
exceptions Hanami renders as error pages count as unhandled, and the 404
|
|
10
|
+
Hanami raises for an unknown path counts as a 404. The agent starts when
|
|
11
|
+
the app boots. The metadata lists each route with its action class and the
|
|
12
|
+
paths of its action, view, and template files, and digests Hanami's layout
|
|
13
|
+
(`app/`, `slices/`, `config/`, `lib/`).
|
|
14
|
+
- Sinatra 3.0 or later: `require "deployangel/sinatra"` and
|
|
15
|
+
`register DeployAngel::Sinatra`, in modular and classic apps. Requests are
|
|
16
|
+
recorded by the route Sinatra ran, with the app's mount point; exceptions
|
|
17
|
+
Sinatra renders as error pages count as unhandled. The metadata lists
|
|
18
|
+
each route with the file it's defined in.
|
|
19
|
+
- The Rack base middleware asks an adapter what status a raised exception
|
|
20
|
+
will be rendered as (`raised_status`); only a 5xx counts as unhandled.
|
|
21
|
+
Rails keeps 500. It also records a request once when it passes through
|
|
22
|
+
more than one DeployAngel middleware (nested Sinatra apps, or a Sinatra
|
|
23
|
+
app mounted in Rails).
|
|
24
|
+
|
|
25
|
+
## 0.1.29 (2026-10-16)
|
|
26
|
+
|
|
27
|
+
- Reports stuck work, so DeployAngel can fail a release in which a job hangs
|
|
28
|
+
or the queue keeps growing while every job still succeeds. Each minute's
|
|
29
|
+
payload says how many jobs are still running and how long the oldest of
|
|
30
|
+
each class has been running (ActiveJob on any adapter, and native Sidekiq
|
|
31
|
+
jobs). Sidekiq and Solid Queue worker processes also read the queue backlog,
|
|
32
|
+
the jobs ready to run and waiting, once a minute from the reporter thread:
|
|
33
|
+
one pipelined Redis call for Sidekiq, one `COUNT` of
|
|
34
|
+
`solid_queue_ready_executions` for Solid Queue. Web processes don't read
|
|
35
|
+
it. Needs a DeployAngel cloud that understands the new fields; older ones
|
|
36
|
+
ignore them.
|
|
37
|
+
|
|
3
38
|
## 0.1.28 (2026-10-12)
|
|
4
39
|
|
|
5
40
|
- Clearer setup for coding agents, from a first-day rehearsal: `setup` says
|
data/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# DeployAngel for Rails
|
|
1
|
+
# DeployAngel for Rails, Hanami, and Sinatra
|
|
2
2
|
|
|
3
|
-
The DeployAngel agent. It watches what your Rails app does in production and
|
|
3
|
+
The DeployAngel agent. It watches what your Rails, Hanami, or Sinatra app does in production and
|
|
4
4
|
reports one small aggregated payload per process per minute. DeployAngel uses
|
|
5
5
|
that to verify every deployment, and to tell you (or your coding agent) when a
|
|
6
6
|
release is **cleared** and you can stop watching it.
|
|
@@ -20,6 +20,59 @@ Set these in production:
|
|
|
20
20
|
DEPLOYANGEL_TOKEN=da_live_... # an ingestion token with the telemetry scope
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
+
### Hanami
|
|
24
|
+
|
|
25
|
+
Requires Hanami 2.2 or later. Hanami has no Railtie, so install it where the
|
|
26
|
+
app is configured:
|
|
27
|
+
|
|
28
|
+
```ruby
|
|
29
|
+
# config/app.rb
|
|
30
|
+
require "hanami"
|
|
31
|
+
require "deployangel/hanami"
|
|
32
|
+
|
|
33
|
+
module Bookshelf
|
|
34
|
+
class App < Hanami::App
|
|
35
|
+
DeployAngel::Hanami.install(self)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
It names each request by the route it matched (through a router resolver that
|
|
41
|
+
builds on any you've configured), records requests and the exceptions Hanami
|
|
42
|
+
renders as error pages, and starts the agent when the app boots (as
|
|
43
|
+
`config.ru`'s `hanami/boot` does). A process that loads the app without booting
|
|
44
|
+
it, such as `hanami console`, doesn't report. Native Sidekiq jobs are recorded
|
|
45
|
+
as in Rails; there's nothing else to set up for them.
|
|
46
|
+
|
|
47
|
+
### Sinatra
|
|
48
|
+
|
|
49
|
+
Requires Sinatra 3.0 or later. Register the extension before the app's
|
|
50
|
+
routes, so it knows the file each route is defined in:
|
|
51
|
+
|
|
52
|
+
```ruby
|
|
53
|
+
require "sinatra/base"
|
|
54
|
+
require "deployangel/sinatra"
|
|
55
|
+
|
|
56
|
+
class Bookshelf < Sinatra::Base
|
|
57
|
+
register DeployAngel::Sinatra
|
|
58
|
+
|
|
59
|
+
get "/books/:id" do
|
|
60
|
+
# ...
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
A classic app registers it at the top level, after `require "sinatra"`. It
|
|
66
|
+
records each request by the route Sinatra ran (after `pass`, the route that
|
|
67
|
+
answered), with the app's mount point from `config.ru` (`map "/api"`), and the
|
|
68
|
+
exceptions Sinatra renders as error pages. A request that passes through more
|
|
69
|
+
than one app (one used as another's middleware) is recorded once. File paths
|
|
70
|
+
are relative to the working directory, the app's root in most deploys. An
|
|
71
|
+
exception raised directly in a route block is named by its file, since the
|
|
72
|
+
block has no method name, so two raised in different routes of the same file
|
|
73
|
+
share a fingerprint; ones raised in helpers or other classes are told apart as
|
|
74
|
+
usual.
|
|
75
|
+
|
|
23
76
|
The agent must know which release it is running. It finds it in this order:
|
|
24
77
|
|
|
25
78
|
1. `DEPLOYANGEL_REVISION` (the commit SHA) and `DEPLOYANGEL_RELEASE_VERSION`, if you set them
|
|
@@ -83,11 +136,16 @@ the dashboard says so.
|
|
|
83
136
|
and a latency histogram, both per app and per route.
|
|
84
137
|
- Routes are recorded as the matched pattern (`GET /users/:id`), never the raw
|
|
85
138
|
path. At most 100 routes are sent per payload; the rest are folded into
|
|
86
|
-
`__other__`.
|
|
139
|
+
`__other__`. In Hanami, a request no route matched is a 404, not an
|
|
140
|
+
exception, though Hanami raises one to render it.
|
|
87
141
|
- Background jobs: attempts, failed attempts, discarded jobs, duration, and
|
|
88
142
|
queue latency, per job class. Works with every ActiveJob adapter (Solid
|
|
89
143
|
Queue, Sidekiq, GoodJob, ...) and with native Sidekiq jobs through a server
|
|
90
144
|
middleware. Failures that `retry_on` or `discard_on` handle still count.
|
|
145
|
+
- Jobs still running at the end of each minute, and how long the oldest of
|
|
146
|
+
each class has been running, so a job that hangs is noticed. Sidekiq and
|
|
147
|
+
Solid Queue worker processes also send the queue backlog (how many jobs are
|
|
148
|
+
waiting), read once a minute off the request path.
|
|
91
149
|
- Release identity, runtime versions, and a per-process instance ID.
|
|
92
150
|
|
|
93
151
|
- Exceptions: a stable fingerprint, the exception class, the first line of the
|
|
@@ -97,9 +155,11 @@ the dashboard says so.
|
|
|
97
155
|
job switched to. Other words are kept: `Payment failed for Jane Doe` is sent
|
|
98
156
|
as it is. If your app's messages might hold personal or health data, [turn
|
|
99
157
|
messages off](#exception-messages).
|
|
100
|
-
- Once per process: the route table
|
|
101
|
-
|
|
102
|
-
|
|
158
|
+
- Once per process: the route table (in Hanami, with each route's action
|
|
159
|
+
class and the paths of its action, view, and template files; in Sinatra,
|
|
160
|
+
with the path of the file each route is defined in), job classes,
|
|
161
|
+
recurring schedules declared for Solid Queue, GoodJob, sidekiq-cron,
|
|
162
|
+
sidekiq-scheduler, or whenever, critical
|
|
103
163
|
flows, and file digests (relative paths and hashes, never file contents) so
|
|
104
164
|
DeployAngel can tell which routes changed in a release.
|
|
105
165
|
Disable digests with `DEPLOYANGEL_FILE_DIGESTS=false`.
|
data/lib/deployangel/agent.rb
CHANGED
|
@@ -86,6 +86,25 @@ module DeployAngel
|
|
|
86
86
|
warn_once(:record_job, "DeployAngel failed to record a job: #{e.class}: #{e.message}")
|
|
87
87
|
end
|
|
88
88
|
|
|
89
|
+
# Marks a job as running until #job_finished, so one that hangs is still
|
|
90
|
+
# reported. Returns the token to pass to it.
|
|
91
|
+
def job_started(job_class)
|
|
92
|
+
return unless @active
|
|
93
|
+
|
|
94
|
+
after_fork! if Process.pid != @pid
|
|
95
|
+
start_reporter
|
|
96
|
+
@running_jobs.start(job_class)
|
|
97
|
+
rescue StandardError => e
|
|
98
|
+
warn_once(:job_started, "DeployAngel failed to record a running job: #{e.class}: #{e.message}")
|
|
99
|
+
nil
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def job_finished(token)
|
|
103
|
+
@running_jobs.finish(token) if token
|
|
104
|
+
rescue StandardError => e
|
|
105
|
+
warn_once(:job_finished, "DeployAngel failed to record a finished job: #{e.class}: #{e.message}")
|
|
106
|
+
end
|
|
107
|
+
|
|
89
108
|
def record_discard(job_class:)
|
|
90
109
|
return unless @active
|
|
91
110
|
|
|
@@ -135,7 +154,9 @@ module DeployAngel
|
|
|
135
154
|
def flush(include_current: false)
|
|
136
155
|
return 0 unless @active
|
|
137
156
|
|
|
138
|
-
@aggregator.drain(include_current: include_current, max_periods: config.max_queued_payloads)
|
|
157
|
+
periods = @aggregator.drain(include_current: include_current, max_periods: config.max_queued_payloads)
|
|
158
|
+
add_stuck_work(periods.last, include_current) if periods.any?
|
|
159
|
+
periods.each do |period|
|
|
139
160
|
@buffer.push(Transport.encode(Protocol.telemetry(period, instance: @instance, release: release,
|
|
140
161
|
runtime: @runtime, capabilities: capabilities)))
|
|
141
162
|
end
|
|
@@ -198,10 +219,24 @@ module DeployAngel
|
|
|
198
219
|
end
|
|
199
220
|
|
|
200
221
|
private
|
|
222
|
+
# What's still running, on the latest period only: as of its end, or
|
|
223
|
+
# now for the in-progress one at shutdown. The backlog is read here, in
|
|
224
|
+
# the reporter thread, but not at shutdown, which has seconds to send.
|
|
225
|
+
def add_stuck_work(period, include_current)
|
|
226
|
+
ended_at = [ period.started_at + Aggregator::PERIOD_SECONDS, @clock.call ].min
|
|
227
|
+
@running_jobs.snapshot(ended_at).each do |job_class, (count, oldest_ms)|
|
|
228
|
+
period.record_running(job_class, count, oldest_ms, config.max_routes)
|
|
229
|
+
end
|
|
230
|
+
period.queue_backlog = QueueBacklog.read unless include_current
|
|
231
|
+
rescue StandardError => e
|
|
232
|
+
warn_once(:stuck_work, "DeployAngel failed to read running jobs: #{e.class}: #{e.message}")
|
|
233
|
+
end
|
|
234
|
+
|
|
201
235
|
def reset_process_state
|
|
202
236
|
@pid = Process.pid
|
|
203
237
|
@instance = Instance.new(env: @env, now: @clock.call)
|
|
204
238
|
@aggregator = Aggregator.new(max_routes: config.max_routes, clock: @clock)
|
|
239
|
+
@running_jobs = RunningJobs.new(clock: @clock)
|
|
205
240
|
@buffer = Buffer.new(config.max_queued_payloads)
|
|
206
241
|
@paused_until = nil
|
|
207
242
|
@metadata_sent = false
|
|
@@ -19,9 +19,16 @@ module DeployAngel
|
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
|
|
22
|
+
# running and oldest_running_ms: jobs still running as the period is
|
|
23
|
+
# sent, and how long the oldest had been running at its end.
|
|
24
|
+
JobStats = Struct.new(:processed, :failed, :discarded, :duration, :queue_latency, :running, :oldest_running_ms) do
|
|
23
25
|
def self.empty
|
|
24
|
-
new(0, 0, 0, Histogram.new, Histogram.new)
|
|
26
|
+
new(0, 0, 0, Histogram.new, Histogram.new, 0, nil)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add_running(count, oldest_ms)
|
|
30
|
+
self.running += count
|
|
31
|
+
self.oldest_running_ms = [ oldest_running_ms, oldest_ms ].compact.max
|
|
25
32
|
end
|
|
26
33
|
|
|
27
34
|
def record(failed, discarded, duration_ms, queue_latency_ms)
|
|
@@ -54,6 +61,8 @@ module DeployAngel
|
|
|
54
61
|
class Period
|
|
55
62
|
attr_reader :started_at, :requests, :status_counts, :unhandled_exceptions, :histogram, :routes,
|
|
56
63
|
:jobs, :job_classes, :exceptions, :exceptions_truncated, :checkpoints
|
|
64
|
+
# Jobs waiting in the job backend's queues, read as the period is sent.
|
|
65
|
+
attr_accessor :queue_backlog
|
|
57
66
|
|
|
58
67
|
def initialize(started_at)
|
|
59
68
|
@started_at = started_at
|
|
@@ -97,6 +106,14 @@ module DeployAngel
|
|
|
97
106
|
(@job_classes[key] ||= JobStats.empty).record(failed, discarded, duration_ms, queue_latency_ms)
|
|
98
107
|
end
|
|
99
108
|
|
|
109
|
+
# A class with jobs running and none finished is listed too, so a job
|
|
110
|
+
# that hangs is reported.
|
|
111
|
+
def record_running(job_class, count, oldest_ms, max_classes)
|
|
112
|
+
@jobs.add_running(count, oldest_ms)
|
|
113
|
+
key = @job_classes.key?(job_class) || @job_classes.size < max_classes - 1 ? job_class : OTHER_ROUTE
|
|
114
|
+
(@job_classes[key] ||= JobStats.empty).add_running(count, oldest_ms)
|
|
115
|
+
end
|
|
116
|
+
|
|
100
117
|
def record_discard(job_class, max_classes)
|
|
101
118
|
@jobs.record_discard
|
|
102
119
|
key = @job_classes.key?(job_class) || @job_classes.size < max_classes - 1 ? job_class : OTHER_ROUTE
|
|
@@ -32,7 +32,7 @@ module DeployAngel
|
|
|
32
32
|
end,
|
|
33
33
|
"exceptions" => period.exceptions.values.map { |e| e.merge("sources" => e["sources"].to_h).compact },
|
|
34
34
|
"exceptions_truncated" => period.exceptions_truncated,
|
|
35
|
-
"jobs" => job_stats(period.jobs),
|
|
35
|
+
"jobs" => job_stats(period.jobs).merge({ "queue_backlog" => period.queue_backlog }.compact),
|
|
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
38
|
# this agent from one that doesn't say where checkpoints happened.
|
|
@@ -42,14 +42,18 @@ module DeployAngel
|
|
|
42
42
|
}
|
|
43
43
|
end
|
|
44
44
|
|
|
45
|
+
# running and oldest_running_ms only when jobs were running.
|
|
45
46
|
def job_stats(stats)
|
|
46
|
-
{
|
|
47
|
+
fields = {
|
|
47
48
|
"processed" => stats.processed,
|
|
48
49
|
"failed" => stats.failed,
|
|
49
50
|
"discarded" => stats.discarded,
|
|
50
51
|
"duration_histogram" => stats.duration.to_protocol,
|
|
51
52
|
"queue_latency_histogram" => stats.queue_latency.to_protocol
|
|
52
53
|
}
|
|
54
|
+
return fields unless stats.running.positive?
|
|
55
|
+
|
|
56
|
+
fields.merge("running" => stats.running, "oldest_running_ms" => stats.oldest_running_ms)
|
|
53
57
|
end
|
|
54
58
|
|
|
55
59
|
def runtime(framework: nil, framework_version: nil)
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeployAngel
|
|
4
|
+
# The jobs this process is running now, so each payload can say how long
|
|
5
|
+
# the oldest of each class has been running: a job that hangs never
|
|
6
|
+
# finishes, so it never shows up in the counts. Starting and finishing is
|
|
7
|
+
# a hash update under a mutex.
|
|
8
|
+
class RunningJobs
|
|
9
|
+
# More than any process runs at once; past it, new jobs go untracked
|
|
10
|
+
# rather than growing memory.
|
|
11
|
+
MAX = 1_000
|
|
12
|
+
|
|
13
|
+
def initialize(clock:)
|
|
14
|
+
@clock = clock
|
|
15
|
+
@jobs = {}.compare_by_identity
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# Returns the token to pass to #finish.
|
|
20
|
+
def start(job_class)
|
|
21
|
+
token = Object.new
|
|
22
|
+
@mutex.synchronize { @jobs[token] = [ job_class.to_s, @clock.call ] if @jobs.size < MAX }
|
|
23
|
+
token
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def finish(token)
|
|
27
|
+
@mutex.synchronize { @jobs.delete(token) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# { job_class => [running, oldest_running_ms] } for jobs that started by
|
|
31
|
+
# at and are still running.
|
|
32
|
+
def snapshot(at)
|
|
33
|
+
@mutex.synchronize do
|
|
34
|
+
@jobs.each_value.with_object({}) do |(job_class, started_at), running|
|
|
35
|
+
next if started_at > at
|
|
36
|
+
|
|
37
|
+
age_ms = ((at - started_at) * 1000).round
|
|
38
|
+
count, oldest = running[job_class]
|
|
39
|
+
running[job_class] = [ count.to_i + 1, [ oldest.to_i, age_ms ].max ]
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeployAngel
|
|
4
|
+
module Hanami
|
|
5
|
+
# The Hanami adapter: route identity from DeployAngel's resolver, and the
|
|
6
|
+
# status Hanami renders a raised exception as. Everything else about
|
|
7
|
+
# recording a request is in DeployAngel::Rack::Http.
|
|
8
|
+
#
|
|
9
|
+
# Hanami renders exceptions in middleware outside the app's own, so this
|
|
10
|
+
# one sees each exception raised, before it's rendered. Hanami also
|
|
11
|
+
# raises to say a route wasn't found, and renders that as a 404, so the
|
|
12
|
+
# status comes from the same mapping Hanami uses.
|
|
13
|
+
class Http < ::DeployAngel::Rack::Http
|
|
14
|
+
def initialize(app, hanami_config = nil)
|
|
15
|
+
super(app)
|
|
16
|
+
@hanami_config = hanami_config
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
def route_pattern(env)
|
|
21
|
+
env[Resolver::ROUTE_PATTERN]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# config.render_error_responses, by the exception class's exact name,
|
|
25
|
+
# as Hanami reads it. Without render_errors (development, tests),
|
|
26
|
+
# the exception reaches the server, which answers 500.
|
|
27
|
+
def raised_status(_env, exception)
|
|
28
|
+
return 500 unless @hanami_config&.render_errors
|
|
29
|
+
|
|
30
|
+
status = @hanami_config.render_error_responses[exception.class.name] || :internal_server_error
|
|
31
|
+
::Rack::Utils.status_code(status)
|
|
32
|
+
rescue StandardError
|
|
33
|
+
500
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeployAngel
|
|
4
|
+
module Hanami
|
|
5
|
+
# The Hanami adapter: the route table, each route's action and the files
|
|
6
|
+
# behind it, and Hanami's layout for the file digests. Schedules read on
|
|
7
|
+
# every framework, and the digests themselves, are in
|
|
8
|
+
# DeployAngel::Metadata.
|
|
9
|
+
class Metadata < ::DeployAngel::Metadata
|
|
10
|
+
DIGEST_GLOBS = %w[app/**/* slices/**/* config/**/* lib/**/* Gemfile.lock].freeze
|
|
11
|
+
SKIPPED_METHODS = %w[HEAD OPTIONS].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(app:, config:, root:, environment:)
|
|
14
|
+
super(config: config, root: root, environment: environment)
|
|
15
|
+
@app = app
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def digest_globs
|
|
19
|
+
DIGEST_GLOBS
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# The routes as `hanami routes` lists them: a second router, built
|
|
23
|
+
# only to be inspected, whose endpoints are never resolved or called.
|
|
24
|
+
def routes
|
|
25
|
+
RouteTable.new(@app).routes.filter_map do |http_method, path, endpoint|
|
|
26
|
+
next if SKIPPED_METHODS.include?(http_method)
|
|
27
|
+
|
|
28
|
+
key = "#{http_method} #{path}"
|
|
29
|
+
next if health_check_path?(path) || @config.ignored_route?(key)
|
|
30
|
+
|
|
31
|
+
route = { "key" => key }
|
|
32
|
+
route["controller"] = endpoint[:class_name] if endpoint[:class_name]
|
|
33
|
+
files = endpoint[:files].filter_map { |file| app_relative(file) }.uniq
|
|
34
|
+
route["files"] = files unless files.empty?
|
|
35
|
+
route
|
|
36
|
+
end.uniq { |route| route["key"] }
|
|
37
|
+
rescue StandardError
|
|
38
|
+
[]
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# [http_method, path, endpoint] for each route, where endpoint is
|
|
43
|
+
# { class_name:, files: }: the action class and its file, view, and
|
|
44
|
+
# templates, or the file a lambda or Rack app is defined in.
|
|
45
|
+
class RouteTable
|
|
46
|
+
# Takes each route's definition from the router.
|
|
47
|
+
class Inspector
|
|
48
|
+
attr_reader :routes
|
|
49
|
+
|
|
50
|
+
def initialize
|
|
51
|
+
@routes = []
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def add_route(route)
|
|
55
|
+
@routes << route
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def call(*) = ""
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def initialize(app)
|
|
62
|
+
@app = app
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def routes
|
|
66
|
+
require "hanami/slice/router"
|
|
67
|
+
require "hanami/slice/routing/resolver"
|
|
68
|
+
|
|
69
|
+
targets = {}
|
|
70
|
+
inspector = Inspector.new
|
|
71
|
+
resolver = Class.new(::Hanami::Slice::Routing::Resolver) do
|
|
72
|
+
# Which slice each `to:` belongs to; nothing is resolved.
|
|
73
|
+
define_method(:call) do |path, endpoint|
|
|
74
|
+
targets[[ path.to_s, endpoint ]] ||= slice
|
|
75
|
+
->(*) {}
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
options = { inspector: inspector, routes: @app.routes, resolver: resolver.new(slice: @app) }
|
|
79
|
+
# Hanami 3's slice router needs the app's inflector; 2.2's takes none.
|
|
80
|
+
options[:inflector] = @app.inflector if router_takes?(:inflector)
|
|
81
|
+
::Hanami::Slice::Router.new(**options) { nil }
|
|
82
|
+
|
|
83
|
+
inspector.routes.map do |route|
|
|
84
|
+
path = route.path.to_s
|
|
85
|
+
[ route.http_method.to_s.upcase, path, endpoint(route.to, targets[[ path, route.to ]] || @app) ]
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
private
|
|
90
|
+
def router_takes?(keyword)
|
|
91
|
+
::Hanami::Slice::Router.instance_method(:initialize).parameters.any? { |type, name| %i[key keyreq].include?(type) && name == keyword }
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def endpoint(to, slice)
|
|
95
|
+
case to
|
|
96
|
+
when String then action(to, slice)
|
|
97
|
+
when Proc then { class_name: nil, files: [ to.source_location&.first ] }
|
|
98
|
+
when Module then { class_name: to.name, files: [ source_file(to.name) ] }
|
|
99
|
+
else { class_name: to.class.name, files: [ source_file(to.class.name) ] }
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# "books.show" in a slice is its Actions::Books::Show, in
|
|
104
|
+
# app/actions/books/show.rb (slices/<name>/actions/... in a slice),
|
|
105
|
+
# rendered by the view and templates at the same name.
|
|
106
|
+
def action(key, slice)
|
|
107
|
+
parts = key.split(".")
|
|
108
|
+
name = [ slice.namespace.name, "Actions", *parts.map { |part| slice.inflector.camelize(part) } ].join("::")
|
|
109
|
+
file = source_file(name)
|
|
110
|
+
files = [ file ]
|
|
111
|
+
if file&.include?("/actions/")
|
|
112
|
+
files << file.sub("/actions/", "/views/")
|
|
113
|
+
templates = File.dirname(file.sub("/actions/", "/templates/"))
|
|
114
|
+
files.concat(Dir.glob(File.join(templates, "#{File.basename(file, ".rb")}.*")))
|
|
115
|
+
end
|
|
116
|
+
{ class_name: name, files: files.compact.select { |path| File.file?(path) } }
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# Loads the class (Zeitwerk autoloads it; nothing is instantiated),
|
|
120
|
+
# since a constant not loaded yet has no source location.
|
|
121
|
+
def source_file(name)
|
|
122
|
+
return unless name
|
|
123
|
+
|
|
124
|
+
Object.const_get(name)
|
|
125
|
+
Object.const_source_location(name)&.first
|
|
126
|
+
rescue NameError, StandardError
|
|
127
|
+
nil
|
|
128
|
+
end
|
|
129
|
+
end
|
|
130
|
+
end
|
|
131
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeployAngel
|
|
4
|
+
module Hanami
|
|
5
|
+
# Hanami's router doesn't record which route a request matched, only its
|
|
6
|
+
# parameters. Its resolver, which turns each route's `to:` into an
|
|
7
|
+
# endpoint as the routes are defined, is told the route's full pattern
|
|
8
|
+
# (scopes' and slices' prefixes included), so this one wraps each
|
|
9
|
+
# endpoint to note the pattern on the request, as Rails' router does.
|
|
10
|
+
# It subclasses whatever resolver the app has configured.
|
|
11
|
+
module Resolver
|
|
12
|
+
ROUTE_PATTERN = "deployangel.route_pattern"
|
|
13
|
+
|
|
14
|
+
def self.for(base)
|
|
15
|
+
return base if base <= Tagging
|
|
16
|
+
|
|
17
|
+
Class.new(base) do
|
|
18
|
+
prepend Tagging
|
|
19
|
+
|
|
20
|
+
def self.name = "DeployAngel::Hanami::Resolver(#{superclass.name})"
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
module Tagging
|
|
25
|
+
def call(path, endpoint)
|
|
26
|
+
resolved = super
|
|
27
|
+
pattern = path.to_s
|
|
28
|
+
lambda do |env, *rest|
|
|
29
|
+
env[ROUTE_PATTERN] = pattern
|
|
30
|
+
resolved.call(env, *rest)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../deployangel"
|
|
4
|
+
require_relative "hanami/resolver"
|
|
5
|
+
require_relative "hanami/http"
|
|
6
|
+
require_relative "hanami/metadata"
|
|
7
|
+
|
|
8
|
+
module DeployAngel
|
|
9
|
+
# The Hanami integration, for Hanami 2.2 or later. Hanami has no Railtie,
|
|
10
|
+
# so the app installs it where it's configured:
|
|
11
|
+
#
|
|
12
|
+
# # config/app.rb
|
|
13
|
+
# require "hanami"
|
|
14
|
+
# require "deployangel/hanami"
|
|
15
|
+
#
|
|
16
|
+
# module Shop
|
|
17
|
+
# class App < Hanami::App
|
|
18
|
+
# DeployAngel::Hanami.install(self)
|
|
19
|
+
# end
|
|
20
|
+
# end
|
|
21
|
+
#
|
|
22
|
+
# Inside this namespace a bare Hanami means DeployAngel::Hanami, so reach
|
|
23
|
+
# for the framework itself as ::Hanami.
|
|
24
|
+
module Hanami
|
|
25
|
+
class << self
|
|
26
|
+
# Names each request's route (through the router's resolver), records
|
|
27
|
+
# requests (through the app's middleware), and starts the agent once
|
|
28
|
+
# the app boots (its container is finalized, as config.ru's
|
|
29
|
+
# hanami/boot does). Safe to call more than once.
|
|
30
|
+
def install(app)
|
|
31
|
+
@installed ||= []
|
|
32
|
+
return app if @installed.include?(app)
|
|
33
|
+
|
|
34
|
+
@installed << app
|
|
35
|
+
config = app.config
|
|
36
|
+
config.router.resolver = Resolver.for(config.router.resolver)
|
|
37
|
+
config.middleware.use(Http, config)
|
|
38
|
+
app.container.after(:finalize) { DeployAngel::Hanami.start(app) }
|
|
39
|
+
DeployAngel::Sidekiq.install
|
|
40
|
+
DeployAngel::QueueBacklog.install
|
|
41
|
+
app
|
|
42
|
+
rescue StandardError => e
|
|
43
|
+
DeployAngel.configuration.logger&.warn("DeployAngel failed to install in Hanami: #{e.class}: #{e.message}")
|
|
44
|
+
app
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# Called as the app boots. The metadata goes in at start, before the
|
|
48
|
+
# reporter thread can run: the code fingerprint is built from its file
|
|
49
|
+
# digests.
|
|
50
|
+
def start(app)
|
|
51
|
+
root = app.root.to_s
|
|
52
|
+
environment = ::Hanami.env.to_s
|
|
53
|
+
DeployAngel.start(
|
|
54
|
+
environment: environment,
|
|
55
|
+
root: root,
|
|
56
|
+
framework: "hanami",
|
|
57
|
+
framework_version: (::Hanami::VERSION if defined?(::Hanami::VERSION)),
|
|
58
|
+
logger: logger(app),
|
|
59
|
+
metadata: Metadata.new(app: app, config: DeployAngel.configuration, root: root, environment: environment)
|
|
60
|
+
)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
def logger(app)
|
|
65
|
+
app["logger"] if app.key?("logger")
|
|
66
|
+
rescue StandardError
|
|
67
|
+
nil
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
data/lib/deployangel/metadata.rb
CHANGED
|
@@ -214,6 +214,21 @@ module DeployAngel
|
|
|
214
214
|
nil
|
|
215
215
|
end
|
|
216
216
|
|
|
217
|
+
# A route at a conventional health-check path ("/healthz"), left out of
|
|
218
|
+
# the route table as the request middleware leaves it out of telemetry.
|
|
219
|
+
def health_check_path?(path)
|
|
220
|
+
segments = path.to_s.split("/").reject(&:empty?)
|
|
221
|
+
Rack::Http::HEALTH_CHECK_PATHS.include?(segments.last) && segments.none? { |segment| segment.start_with?(":", "*") }
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
# A source file in the app, relative to its root; nil for one in a gem.
|
|
225
|
+
def app_relative(file)
|
|
226
|
+
return unless file&.start_with?("#{@root}/")
|
|
227
|
+
|
|
228
|
+
path = file.delete_prefix("#{@root}/")
|
|
229
|
+
path unless path.start_with?("vendor/", ".bundle/")
|
|
230
|
+
end
|
|
231
|
+
|
|
217
232
|
# The path, or the same name with .yaml if only that exists.
|
|
218
233
|
def yaml_path(path)
|
|
219
234
|
[ path, path.sub(/\.yml\z/, ".yaml") ].find { |candidate| File.file?(candidate) }
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeployAngel
|
|
4
|
+
# Reads how many jobs are ready to run and waiting in the job backend's
|
|
5
|
+
# queues. Only job worker processes read it, once a minute from the
|
|
6
|
+
# reporter thread as they send telemetry, never in a request or job. Every
|
|
7
|
+
# worker sees the same queues, and the cloud keeps the largest reading.
|
|
8
|
+
module QueueBacklog
|
|
9
|
+
module_function
|
|
10
|
+
|
|
11
|
+
# Solid Queue runs each worker in its own process, forked by the
|
|
12
|
+
# supervisor, so it's marked as each one starts.
|
|
13
|
+
def install
|
|
14
|
+
return if @installed || !defined?(::SolidQueue) || !::SolidQueue.respond_to?(:on_worker_start)
|
|
15
|
+
|
|
16
|
+
@installed = true
|
|
17
|
+
::SolidQueue.on_worker_start { worker!(:solid_queue) }
|
|
18
|
+
rescue StandardError
|
|
19
|
+
nil
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# Called where the backend says this process works its queues: Sidekiq's
|
|
23
|
+
# server configuration, or a Solid Queue worker starting.
|
|
24
|
+
def worker!(backend)
|
|
25
|
+
@backend = backend
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def backend
|
|
29
|
+
@backend
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Jobs waiting, or nil when this process doesn't work a queue it can read.
|
|
33
|
+
def read
|
|
34
|
+
case @backend
|
|
35
|
+
when :sidekiq then sidekiq
|
|
36
|
+
when :solid_queue then solid_queue
|
|
37
|
+
end
|
|
38
|
+
rescue StandardError, LoadError
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
# One pipelined call for every queue's length.
|
|
43
|
+
def sidekiq
|
|
44
|
+
require "sidekiq/api"
|
|
45
|
+
::Sidekiq::Stats.new.enqueued
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Ready executions only: scheduled and blocked jobs aren't waiting on a
|
|
49
|
+
# worker. The connection goes straight back to the pool.
|
|
50
|
+
def solid_queue
|
|
51
|
+
model = ::SolidQueue::ReadyExecution
|
|
52
|
+
model.connection_pool.with_connection { model.count }
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -10,7 +10,9 @@ module DeployAngel
|
|
|
10
10
|
# An adapter subclasses it, answers route_pattern (and rendered_exception
|
|
11
11
|
# if the framework renders exceptions itself), and is inserted at the top
|
|
12
12
|
# of the stack, so it sees the final status after the framework's own
|
|
13
|
-
# error pages.
|
|
13
|
+
# error pages. A framework that renders exceptions outside its app
|
|
14
|
+
# middleware instead answers raised_status. DeployAngel::Rails::Http and
|
|
15
|
+
# DeployAngel::Hanami::Http are the adapters.
|
|
14
16
|
#
|
|
15
17
|
# A request is only counted against a route pattern the framework
|
|
16
18
|
# matched, never the raw path, which keeps IDs out of route keys.
|
|
@@ -26,9 +28,16 @@ module DeployAngel
|
|
|
26
28
|
@app = app
|
|
27
29
|
end
|
|
28
30
|
|
|
31
|
+
# Set on a request once a DeployAngel middleware records it. Apps nest
|
|
32
|
+
# (a Sinatra app used as another's middleware, or mounted inside
|
|
33
|
+
# Rails), so a request can pass through more than one; only the
|
|
34
|
+
# outermost records it.
|
|
35
|
+
RECORDED = "deployangel.recorded"
|
|
36
|
+
|
|
29
37
|
def call(env)
|
|
30
|
-
return @app.call(env)
|
|
38
|
+
return @app.call(env) if !DeployAngel.recording? || env[RECORDED]
|
|
31
39
|
|
|
40
|
+
env[RECORDED] = true
|
|
32
41
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
33
42
|
Redaction.request_host = env["HTTP_HOST"]
|
|
34
43
|
# Checkpoints recorded from here until the app returns count as
|
|
@@ -37,7 +46,10 @@ module DeployAngel
|
|
|
37
46
|
begin
|
|
38
47
|
status, headers, body = @app.call(env)
|
|
39
48
|
rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
|
|
40
|
-
|
|
49
|
+
# A framework that renders exceptions outside this middleware turns
|
|
50
|
+
# some into a 4xx; only one that ends in a 5xx is unhandled.
|
|
51
|
+
status = raised_status(env, e)
|
|
52
|
+
record(env, status, started, unhandled: status >= 500, exception: (e if status >= 500))
|
|
41
53
|
raise
|
|
42
54
|
ensure
|
|
43
55
|
ExecutionContext.restore(previous_context)
|
|
@@ -65,6 +77,12 @@ module DeployAngel
|
|
|
65
77
|
nil
|
|
66
78
|
end
|
|
67
79
|
|
|
80
|
+
# The status a raised exception will be rendered as, by a framework
|
|
81
|
+
# that renders it after this middleware. 500 unless an adapter knows.
|
|
82
|
+
def raised_status(_env, _exception)
|
|
83
|
+
500
|
|
84
|
+
end
|
|
85
|
+
|
|
68
86
|
def route_key(env)
|
|
69
87
|
pattern = route_pattern(env) or return nil
|
|
70
88
|
|
|
@@ -12,12 +12,14 @@ module DeployAngel
|
|
|
12
12
|
module ActiveJob
|
|
13
13
|
STATE = :@__deployangel_job_state
|
|
14
14
|
PREVIOUS_CONTEXT = :@__deployangel_previous_context
|
|
15
|
+
RUNNING = :@__deployangel_running
|
|
15
16
|
|
|
16
17
|
# Marks the fiber as running a job for as long as perform.active_job
|
|
17
|
-
# lasts, so checkpoints recorded by the job say so
|
|
18
|
-
#
|
|
19
|
-
#
|
|
20
|
-
#
|
|
18
|
+
# lasts, so checkpoints recorded by the job say so, and the job as
|
|
19
|
+
# running, so one that hangs is reported. ActiveSupport calls finish
|
|
20
|
+
# from an ensure, so the previous context (an HTTP request, for a job
|
|
21
|
+
# performed inline) comes back even when the job raises. Both are kept
|
|
22
|
+
# on the job, so only a perform that started here finishes them.
|
|
21
23
|
module ContextListener
|
|
22
24
|
module_function
|
|
23
25
|
|
|
@@ -26,6 +28,7 @@ module DeployAngel
|
|
|
26
28
|
return unless job && DeployAngel.recording?
|
|
27
29
|
|
|
28
30
|
job.instance_variable_set(PREVIOUS_CONTEXT, ExecutionContext.enter(ExecutionContext::JOB))
|
|
31
|
+
job.instance_variable_set(RUNNING, DeployAngel.job_started(job.class.name))
|
|
29
32
|
rescue StandardError
|
|
30
33
|
nil
|
|
31
34
|
end
|
|
@@ -35,6 +38,7 @@ module DeployAngel
|
|
|
35
38
|
return unless job&.instance_variable_defined?(PREVIOUS_CONTEXT)
|
|
36
39
|
|
|
37
40
|
ExecutionContext.restore(job.remove_instance_variable(PREVIOUS_CONTEXT))
|
|
41
|
+
DeployAngel.job_finished(job.remove_instance_variable(RUNNING)) if job.instance_variable_defined?(RUNNING)
|
|
38
42
|
rescue StandardError
|
|
39
43
|
nil
|
|
40
44
|
end
|
|
@@ -12,6 +12,7 @@ module DeployAngel
|
|
|
12
12
|
initializer "deployangel.jobs" do
|
|
13
13
|
ActiveSupport.on_load(:active_job) { DeployAngel::Rails::ActiveJob.install }
|
|
14
14
|
DeployAngel::Sidekiq.install
|
|
15
|
+
DeployAngel::QueueBacklog.install
|
|
15
16
|
end
|
|
16
17
|
|
|
17
18
|
# Handled reports (Rails.error.handle / Rails.error.report) are shown for
|
data/lib/deployangel/sidekiq.rb
CHANGED
|
@@ -17,6 +17,7 @@ module DeployAngel
|
|
|
17
17
|
started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
18
18
|
latency = DeployAngel::Sidekiq.queue_latency_ms(job)
|
|
19
19
|
previous_context = ExecutionContext.enter(ExecutionContext::JOB)
|
|
20
|
+
running = DeployAngel.job_started(job["class"])
|
|
20
21
|
begin
|
|
21
22
|
yield
|
|
22
23
|
rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
|
|
@@ -25,6 +26,7 @@ module DeployAngel
|
|
|
25
26
|
raise
|
|
26
27
|
ensure
|
|
27
28
|
ExecutionContext.restore(previous_context)
|
|
29
|
+
DeployAngel.job_finished(running)
|
|
28
30
|
end
|
|
29
31
|
record(job, started, latency, failed: false)
|
|
30
32
|
end
|
|
@@ -46,6 +48,7 @@ module DeployAngel
|
|
|
46
48
|
@installed = true
|
|
47
49
|
DeployAngel.add_capability("jobs")
|
|
48
50
|
::Sidekiq.configure_server do |config|
|
|
51
|
+
QueueBacklog.worker!(:sidekiq)
|
|
49
52
|
config.server_middleware { |chain| chain.add(ServerMiddleware) }
|
|
50
53
|
# Jobs that exhausted their retries are discarded.
|
|
51
54
|
config.death_handlers << ->(job, _error) { record_death(job) } if config.respond_to?(:death_handlers)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeployAngel
|
|
4
|
+
module Sinatra
|
|
5
|
+
# The Sinatra adapter: route identity from the route Sinatra matched,
|
|
6
|
+
# with the app's mount point, and the exceptions Sinatra renders itself.
|
|
7
|
+
# Everything else about recording a request is in DeployAngel::Rack::Http.
|
|
8
|
+
class Http < ::DeployAngel::Rack::Http
|
|
9
|
+
def initialize(app, sinatra_app = nil)
|
|
10
|
+
super(app)
|
|
11
|
+
@sinatra_app = sinatra_app
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
# Sinatra names the route it ran ("GET /books/:id"), the last one when
|
|
16
|
+
# a route passes, but not the mount point (SCRIPT_NAME), which
|
|
17
|
+
# Sinatra strips from the path it matches.
|
|
18
|
+
def route_pattern(env)
|
|
19
|
+
route = env["sinatra.route"] or return nil
|
|
20
|
+
|
|
21
|
+
pattern = route.split(" ", 2).last
|
|
22
|
+
mount = env["SCRIPT_NAME"].to_s.chomp("/")
|
|
23
|
+
note_mount(mount) unless mount.empty?
|
|
24
|
+
"#{mount}#{pattern}"
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
# Sinatra rescues an exception, renders its error page, and keeps the
|
|
28
|
+
# exception here.
|
|
29
|
+
def rendered_exception(env)
|
|
30
|
+
env["sinatra.error"]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def note_mount(mount)
|
|
34
|
+
DeployAngel::Sinatra.mounts[@sinatra_app] ||= mount if @sinatra_app
|
|
35
|
+
rescue StandardError
|
|
36
|
+
nil
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module DeployAngel
|
|
4
|
+
module Sinatra
|
|
5
|
+
# The Sinatra adapter: the route table of every app that registered the
|
|
6
|
+
# extension, with each route's file, and a typical Sinatra layout for the
|
|
7
|
+
# file digests. Schedules read on every framework, and the digests
|
|
8
|
+
# themselves, are in DeployAngel::Metadata.
|
|
9
|
+
class Metadata < ::DeployAngel::Metadata
|
|
10
|
+
DIGEST_GLOBS = %w[*.rb *.ru app/**/* lib/**/* config/**/* routes/**/* views/**/* models/**/* helpers/**/*
|
|
11
|
+
controllers/**/* db/migrate/**/* Gemfile.lock].freeze
|
|
12
|
+
SKIPPED_METHODS = %w[HEAD OPTIONS].freeze
|
|
13
|
+
|
|
14
|
+
def digest_globs
|
|
15
|
+
DIGEST_GLOBS
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def routes
|
|
19
|
+
DeployAngel::Sinatra.apps.flat_map { |app| app_routes(app) }
|
|
20
|
+
.uniq { |route| route["key"] }
|
|
21
|
+
rescue StandardError
|
|
22
|
+
[]
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
def app_routes(app)
|
|
27
|
+
mount = DeployAngel::Sinatra.mounts[app].to_s
|
|
28
|
+
app.routes.flat_map do |verb, signatures|
|
|
29
|
+
next [] if SKIPPED_METHODS.include?(verb)
|
|
30
|
+
|
|
31
|
+
signatures.filter_map do |pattern, *|
|
|
32
|
+
path = pattern.to_s
|
|
33
|
+
key = "#{verb} #{mount}#{path}"
|
|
34
|
+
next if health_check_path?(path) || @config.ignored_route?(key)
|
|
35
|
+
|
|
36
|
+
route = { "key" => key }
|
|
37
|
+
files = Array(DeployAngel::Sinatra.route_files[[ verb, path ]]).filter_map { |file| app_relative(file) }.uniq
|
|
38
|
+
route["files"] = files unless files.empty?
|
|
39
|
+
route
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
rescue StandardError
|
|
43
|
+
[]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../deployangel"
|
|
4
|
+
require_relative "sinatra/http"
|
|
5
|
+
require_relative "sinatra/metadata"
|
|
6
|
+
|
|
7
|
+
module DeployAngel
|
|
8
|
+
# The Sinatra integration, for Sinatra 3.0 or later, as an extension:
|
|
9
|
+
#
|
|
10
|
+
# require "sinatra/base"
|
|
11
|
+
# require "deployangel/sinatra"
|
|
12
|
+
#
|
|
13
|
+
# class Bookshelf < Sinatra::Base
|
|
14
|
+
# register DeployAngel::Sinatra
|
|
15
|
+
# # routes after it, so their files are known
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# A classic app registers it at the top level, after `require "sinatra"`.
|
|
19
|
+
# Inside this namespace a bare Sinatra means DeployAngel::Sinatra, so reach
|
|
20
|
+
# for the framework itself as ::Sinatra.
|
|
21
|
+
module Sinatra
|
|
22
|
+
class << self
|
|
23
|
+
# The apps that registered the extension, and each route's file, by
|
|
24
|
+
# [verb, path]: Sinatra hands an extension each route's own block as
|
|
25
|
+
# it's defined, while the route table holds only Sinatra's wrapper.
|
|
26
|
+
def apps
|
|
27
|
+
@apps ||= []
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def route_files
|
|
31
|
+
@route_files ||= {}
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# Where each app was mounted (a map "/api" in config.ru), as its
|
|
35
|
+
# requests show it, since the routes it declares don't include it.
|
|
36
|
+
def mounts
|
|
37
|
+
@mounts ||= {}
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Records the app's requests (through its middleware) and starts the
|
|
41
|
+
# agent, once per process.
|
|
42
|
+
def registered(app)
|
|
43
|
+
return if apps.include?(app)
|
|
44
|
+
|
|
45
|
+
apps << app
|
|
46
|
+
app.use(Http, app)
|
|
47
|
+
DeployAngel::Sidekiq.install
|
|
48
|
+
DeployAngel::QueueBacklog.install
|
|
49
|
+
start(app) unless DeployAngel.agent
|
|
50
|
+
rescue StandardError => e
|
|
51
|
+
DeployAngel.configuration.logger&.warn("DeployAngel failed to install in Sinatra: #{e.class}: #{e.message}")
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def route_added(verb, path, block)
|
|
55
|
+
file = block&.source_location&.first
|
|
56
|
+
(route_files[[ verb.to_s, path.to_s ]] ||= []) << file if file
|
|
57
|
+
rescue StandardError
|
|
58
|
+
nil
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The metadata goes in at start, before the reporter thread can run:
|
|
62
|
+
# the code fingerprint is built from its file digests. The working
|
|
63
|
+
# directory is the app's root, as in most deploys.
|
|
64
|
+
def start(app)
|
|
65
|
+
root = Dir.pwd
|
|
66
|
+
environment = app.environment.to_s
|
|
67
|
+
DeployAngel.start(
|
|
68
|
+
environment: environment,
|
|
69
|
+
root: root,
|
|
70
|
+
framework: "sinatra",
|
|
71
|
+
framework_version: (::Sinatra::VERSION if defined?(::Sinatra::VERSION)),
|
|
72
|
+
metadata: Metadata.new(config: DeployAngel.configuration, root: root, environment: environment)
|
|
73
|
+
)
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/deployangel/version.rb
CHANGED
data/lib/deployangel.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "deployangel/core/fingerprint"
|
|
|
8
8
|
require_relative "deployangel/core/release"
|
|
9
9
|
require_relative "deployangel/core/instance"
|
|
10
10
|
require_relative "deployangel/core/aggregator"
|
|
11
|
+
require_relative "deployangel/core/running_jobs"
|
|
11
12
|
require_relative "deployangel/core/protocol"
|
|
12
13
|
require_relative "deployangel/core/execution_context"
|
|
13
14
|
require_relative "deployangel/core/buffer"
|
|
@@ -57,6 +58,14 @@ module DeployAngel
|
|
|
57
58
|
agent&.record_job(**attributes)
|
|
58
59
|
end
|
|
59
60
|
|
|
61
|
+
def job_started(job_class)
|
|
62
|
+
agent&.job_started(job_class)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def job_finished(token)
|
|
66
|
+
agent&.job_finished(token)
|
|
67
|
+
end
|
|
68
|
+
|
|
60
69
|
def record_exception(exception, **options)
|
|
61
70
|
agent&.record_exception(exception, **options)
|
|
62
71
|
end
|
|
@@ -128,6 +137,7 @@ require_relative "deployangel/rails/error_subscriber"
|
|
|
128
137
|
require_relative "deployangel/tasks"
|
|
129
138
|
require_relative "deployangel/metadata"
|
|
130
139
|
require_relative "deployangel/rails/metadata"
|
|
140
|
+
require_relative "deployangel/queue_backlog"
|
|
131
141
|
require_relative "deployangel/sidekiq"
|
|
132
142
|
require_relative "deployangel/apartment"
|
|
133
143
|
require_relative "deployangel/rails/railtie" if defined?(::Rails::Railtie)
|
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.30
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Jordan Owens
|
|
@@ -45,13 +45,19 @@ files:
|
|
|
45
45
|
- lib/deployangel/core/protocol.rb
|
|
46
46
|
- lib/deployangel/core/redaction.rb
|
|
47
47
|
- lib/deployangel/core/release.rb
|
|
48
|
+
- lib/deployangel/core/running_jobs.rb
|
|
48
49
|
- lib/deployangel/core/transport.rb
|
|
49
50
|
- lib/deployangel/credentials.rb
|
|
50
51
|
- lib/deployangel/doctor.rb
|
|
51
52
|
- lib/deployangel/exerciser.rb
|
|
52
53
|
- lib/deployangel/fork_hook.rb
|
|
54
|
+
- lib/deployangel/hanami.rb
|
|
55
|
+
- lib/deployangel/hanami/http.rb
|
|
56
|
+
- lib/deployangel/hanami/metadata.rb
|
|
57
|
+
- lib/deployangel/hanami/resolver.rb
|
|
53
58
|
- lib/deployangel/mcp.rb
|
|
54
59
|
- lib/deployangel/metadata.rb
|
|
60
|
+
- lib/deployangel/queue_backlog.rb
|
|
55
61
|
- lib/deployangel/rack/http.rb
|
|
56
62
|
- lib/deployangel/rails/active_job.rb
|
|
57
63
|
- lib/deployangel/rails/error_subscriber.rb
|
|
@@ -59,6 +65,9 @@ files:
|
|
|
59
65
|
- lib/deployangel/rails/metadata.rb
|
|
60
66
|
- lib/deployangel/rails/railtie.rb
|
|
61
67
|
- lib/deployangel/sidekiq.rb
|
|
68
|
+
- lib/deployangel/sinatra.rb
|
|
69
|
+
- lib/deployangel/sinatra/http.rb
|
|
70
|
+
- lib/deployangel/sinatra/metadata.rb
|
|
62
71
|
- lib/deployangel/tasks.rb
|
|
63
72
|
- lib/deployangel/verification_waiter.rb
|
|
64
73
|
- lib/deployangel/version.rb
|
|
@@ -88,5 +97,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
88
97
|
requirements: []
|
|
89
98
|
rubygems_version: 3.6.9
|
|
90
99
|
specification_version: 4
|
|
91
|
-
summary: 'DeployAngel agent for Rails: production verification
|
|
100
|
+
summary: 'DeployAngel agent for Rails, Hanami, and Sinatra: production verification
|
|
101
|
+
for every deployment.'
|
|
92
102
|
test_files: []
|