deployangel 0.1.29 → 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 +22 -0
- data/README.md +62 -6
- 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/rack/http.rb +21 -3
- 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
- metadata +10 -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,27 @@
|
|
|
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
|
+
|
|
3
25
|
## 0.1.29 (2026-10-16)
|
|
4
26
|
|
|
5
27
|
- 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
|
|
105
|
-
|
|
106
|
-
|
|
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`.
|
|
@@ -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) }
|
|
@@ -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
|
|
|
@@ -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
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
|
|
@@ -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
|
|
100
|
+
summary: 'DeployAngel agent for Rails, Hanami, and Sinatra: production verification
|
|
101
|
+
for every deployment.'
|
|
94
102
|
test_files: []
|