deployangel 0.1.29 → 0.1.31

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: e62d94a1e10efd0431f1cfad4f7aad29f954481919a609f8112e5f517291eeb1
4
- data.tar.gz: 408bcc12ae37d4f93e74e5e31ad6a8b921b30481cedf81f84055c07c62677af0
3
+ metadata.gz: 9645adb2fe301ec8c10cfb61f303d8cdb5465b350889307ff82692814f531d9a
4
+ data.tar.gz: 109ef3ffc37b149229be33b46463564860ceef9168b5eb26b057d82f20d66d71
5
5
  SHA512:
6
- metadata.gz: e26a0379ee22236add3b1742a3ef174ebd3f39def909d3e230b4fadce49b1f2a3f62daa41c2aa02e05e5c12e59a78b9d6c94b7436bca9b986c56b20053f49783
7
- data.tar.gz: 9d7f93900e16f63f1ba593063a31b0d03eb3f218877c65ea6c5a9b13153bd9a3520c8e5555370f8d94e53d89741f0a709143483b8ffa196e0e2e23f9a42a4b31
6
+ metadata.gz: cf9b49285346e69ee69e1fd1ce5cf07bef673cfa0b4cafa9ee06bedf42cf8ba72bb55dfa5d6534247726c5528c447f339436064c7ab23a5bd001506e683f325f
7
+ data.tar.gz: b843e7f6e991b6a7f18a42781be35bef1f353b1580a0d34dce234d39d1ed012393fab3ef227ef8e3bbede6a8b32fb9504883c72783196098ffc2344726642242
data/CHANGELOG.md CHANGED
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.31 (2026-10-21)
4
+
5
+ - The instructions `deployangel install agents` writes to `AGENTS.md` now tell
6
+ coding agents to check recent releases and late regressions at the start
7
+ of a session, and to read `fix_check` in the verdict after deploying a fix
8
+ for a failed release. Run it again to update them.
9
+
10
+ ## 0.1.30 (2026-10-16)
11
+
12
+ - Hanami 2.2 or later: `require "deployangel/hanami"` and
13
+ `DeployAngel::Hanami.install(self)` in the app's class. Requests are
14
+ recorded by the route pattern they matched, scopes' and slices' prefixes
15
+ included, through a router resolver that builds on any the app configured;
16
+ exceptions Hanami renders as error pages count as unhandled, and the 404
17
+ Hanami raises for an unknown path counts as a 404. The agent starts when
18
+ the app boots. The metadata lists each route with its action class and the
19
+ paths of its action, view, and template files, and digests Hanami's layout
20
+ (`app/`, `slices/`, `config/`, `lib/`).
21
+ - Sinatra 3.0 or later: `require "deployangel/sinatra"` and
22
+ `register DeployAngel::Sinatra`, in modular and classic apps. Requests are
23
+ recorded by the route Sinatra ran, with the app's mount point; exceptions
24
+ Sinatra renders as error pages count as unhandled. The metadata lists
25
+ each route with the file it's defined in.
26
+ - The Rack base middleware asks an adapter what status a raised exception
27
+ will be rendered as (`raised_status`); only a 5xx counts as unhandled.
28
+ Rails keeps 500. It also records a request once when it passes through
29
+ more than one DeployAngel middleware (nested Sinatra apps, or a Sinatra
30
+ app mounted in Rails).
31
+
3
32
  ## 0.1.29 (2026-10-16)
4
33
 
5
34
  - Reports stuck work, so DeployAngel can fail a release in which a job hangs
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,7 +136,8 @@ 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
@@ -101,9 +155,11 @@ the dashboard says so.
101
155
  job switched to. Other words are kept: `Payment failed for Jane Doe` is sent
102
156
  as it is. If your app's messages might hold personal or health data, [turn
103
157
  messages off](#exception-messages).
104
- - Once per process: the route table, job classes, recurring schedules
105
- declared for Solid Queue, GoodJob, sidekiq-cron, sidekiq-scheduler, or
106
- 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
107
163
  flows, and file digests (relative paths and hashes, never file contents) so
108
164
  DeployAngel can tell which routes changed in a release.
109
165
  Disable digests with `DEPLOYANGEL_FILE_DIGESTS=false`.
@@ -121,6 +121,13 @@ module DeployAngel
121
121
  coverage and a full baseline), so don't call a medium clearance weak.
122
122
  Warnings (`suspicious: true`) aren't failures: report them as "watching
123
123
  closely:" and the `watching_closely` items, never as "suspicious".
124
+ - After deploying a fix for a failed release, read `fix_check` in the verdict
125
+ (`--format=json` with the CLI): `fixed` means every earlier problem is
126
+ healthy again. Otherwise report what's still happening or not confirmed yet.
127
+ - At the start of a session, before changing code: call `list_deployments`
128
+ and `list_late_regressions` (or run `bundle exec deployangel status`). If a
129
+ recent release failed, isn't cleared yet, or has a late regression, mention
130
+ it before other work.
124
131
  #{AGENTS_END}
125
132
  MARKDOWN
126
133
 
@@ -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) }
@@ -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
 
@@ -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.29"
4
+ VERSION = "0.1.31"
5
5
  end
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.29
4
+ version: 0.1.31
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Owens
@@ -51,6 +51,10 @@ files:
51
51
  - lib/deployangel/doctor.rb
52
52
  - lib/deployangel/exerciser.rb
53
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
54
58
  - lib/deployangel/mcp.rb
55
59
  - lib/deployangel/metadata.rb
56
60
  - lib/deployangel/queue_backlog.rb
@@ -61,6 +65,9 @@ files:
61
65
  - lib/deployangel/rails/metadata.rb
62
66
  - lib/deployangel/rails/railtie.rb
63
67
  - lib/deployangel/sidekiq.rb
68
+ - lib/deployangel/sinatra.rb
69
+ - lib/deployangel/sinatra/http.rb
70
+ - lib/deployangel/sinatra/metadata.rb
64
71
  - lib/deployangel/tasks.rb
65
72
  - lib/deployangel/verification_waiter.rb
66
73
  - lib/deployangel/version.rb
@@ -90,5 +97,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
90
97
  requirements: []
91
98
  rubygems_version: 3.6.9
92
99
  specification_version: 4
93
- 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.'
94
102
  test_files: []