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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fde8310f8129827b4b759369146c2346542a776ab13ac91fe7001617e93a7ebb
4
- data.tar.gz: 15a216435704ea45fce03e0a1433d615dc28f6c07fdd6e33c90bebed23361779
3
+ metadata.gz: cb73e91bc627176c422a77379e49c662588f866524d591d367f4e6e44368ee57
4
+ data.tar.gz: ffff6fa5fe0ce44ba2f17fb58e689eb541c5ad04549bb684ffa17d6995bc3bc4
5
5
  SHA512:
6
- metadata.gz: e6e7ae24a04e3999af5865e2cf5be14054cdfc92b5644b1a4f5d8bd56c407faf874b122db23508bbcb3b268e461a89d68a16a3a235e166cf457e10e063a4c3f9
7
- data.tar.gz: 3e2bb9e11c4d8b35134fdb9088beabcaf0ba5b99b484c9e4b0e2dfc3b710aa544507b55f6a3a222c0446d4b6edbec0f935f0049f03e2039cc19e253767560530
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, job classes, recurring schedules
101
- declared for Solid Queue, GoodJob, sidekiq-cron, sidekiq-scheduler, or
102
- whenever, critical
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`.
@@ -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).each do |period|
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
- JobStats = Struct.new(:processed, :failed, :discarded, :duration, :queue_latency) do
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
@@ -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. DeployAngel::Rails::Http is the Rails adapter.
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) unless DeployAngel.recording?
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
- record(env, 500, started, unhandled: true, exception: e)
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. ActiveSupport calls
18
- # finish from an ensure, so the previous context (an HTTP request, for
19
- # a job performed inline) comes back even when the job raises. It is
20
- # kept on the job, so only a perform that started here restores.
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.28"
4
+ VERSION = "0.1.30"
5
5
  end
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.28
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 for every deployment.'
100
+ summary: 'DeployAngel agent for Rails, Hanami, and Sinatra: production verification
101
+ for every deployment.'
92
102
  test_files: []