deployangel 0.1.21 → 0.1.23

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: 2a115a5a35d86c5edce65dad1cdf3947833416ea66f47c69da18b85de3047153
4
- data.tar.gz: ec954e62605c6452d3e374896085343cb6a12910e0b821794342a2e0a6dc0426
3
+ metadata.gz: 2635666118ec2250b5414245e3ea0aa1026d30e4369788e2c82bf6e6b0a52f71
4
+ data.tar.gz: 0fac6aa9bf419e2ad7a93a289902c94fa94a6d0b42c1418d7370afbaf90501cd
5
5
  SHA512:
6
- metadata.gz: 4c18ca61ecff8936bdfa7f4a5900fd27b96c2a787094e504593fae78f62eaee3c7d4ea11e74811b619313c1a726bce8e4644f5a6319c687450ff81629acdd2a5
7
- data.tar.gz: 2754b3cc315c626df0dc57de4723721caa668f69b7b523a0ca4eaecaf4f84e2f2ec7f9f40f366be34f9a1989ff3c9b4650f1d02dc81b976218d2b3daf5e3968c
6
+ metadata.gz: 41f77d3e77fae4bccf35c82570582e01501246314c540d6bf6d227cdcd15e033237d62d9b0b2769e80766f53293982b4e8fe07e644ced1e99d93ca13a57b93f2
7
+ data.tar.gz: 674cb4c4972c88d861375904572c1048f247f041400c52b8d5380f44b3ff76d8b6f473362b404bedcf2377e1b6811cf96eea39f5cb9934b07271ddd627376afa
data/CHANGELOG.md CHANGED
@@ -1,5 +1,26 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.23 (2026-10-09)
4
+
5
+ - GoodJob's cron (`config.good_job.cron`, or `GOOD_JOB_CRON`) is read from
6
+ the processes that run it, so its jobs are expected on schedule. Entries
7
+ whose schedule or class is a lambda, or that are off by default, are
8
+ left out; schedules without a zone are read in the server's.
9
+
10
+ ## 0.1.22 (2026-10-09)
11
+
12
+ - Work scheduled outside the app's own scheduler is watched too.
13
+ `config.recurring_jobs` declares it by the name it runs under (a job
14
+ class, `"rake <task>"`, or a `DeployAngel.task` name) with a cron line
15
+ or Fugit's words, for cron, Heroku Scheduler, or a Kubernetes CronJob.
16
+ - `DeployAngel.task("name") { ... }` records plain code that a scheduler
17
+ starts as a run of that name, with its duration and whether it raised.
18
+ - Rake tasks a schedule names are recorded with no code change, when the
19
+ task loads the app (`:environment`).
20
+ - The whenever gem's `config/schedule.rb` is read with whenever's own
21
+ parser, when whenever is in the bundle: rake tasks and runners that
22
+ perform a job class are expected on schedule, in the server's zone.
23
+
3
24
  ## 0.1.21 (2026-10-09)
4
25
 
5
26
  - `deployangel exercise` stops requesting a page after its first 404, 405,
data/README.md CHANGED
@@ -98,7 +98,8 @@ the dashboard says so.
98
98
  as it is. If your app's messages might hold personal or health data, [turn
99
99
  messages off](#exception-messages).
100
100
  - Once per process: the route table, job classes, recurring schedules
101
- declared for Solid Queue, sidekiq-cron, or sidekiq-scheduler, critical
101
+ declared for Solid Queue, GoodJob, sidekiq-cron, sidekiq-scheduler, or
102
+ whenever, critical
102
103
  flows, and file digests (relative paths and hashes, never file contents) so
103
104
  DeployAngel can tell which routes changed in a release.
104
105
  Disable digests with `DEPLOYANGEL_FILE_DIGESTS=false`.
@@ -210,6 +211,50 @@ end
210
211
  Jobs that exist only in Redis, such as ones created in code or in the
211
212
  Sidekiq web UI, aren't read.
212
213
 
214
+ GoodJob's cron (`config.good_job.cron`, or `GOOD_JOB_CRON`) is read from the
215
+ processes that run it, the ones with cron enabled. Schedules without a zone
216
+ are read in the server's, as GoodJob reads them. An entry whose `cron:` or
217
+ `class:` is a lambda, or that's `enabled_by_default: false`, isn't expected.
218
+ The agent doesn't see entries turned off in GoodJob's dashboard, so one you
219
+ switch off there will look missed after the next deploy.
220
+
221
+ #### Cron, Heroku Scheduler, and whenever
222
+
223
+ Work that something outside the app starts on a schedule, such as cron,
224
+ Heroku Scheduler, or a Kubernetes CronJob, is invisible to those files.
225
+ Declare it by the name it runs under, with a cron line or words like
226
+ `"every day at 4am"`, in the server's time zone unless you add one
227
+ (`"0 3 * * * America/New_York"`):
228
+
229
+ ```ruby
230
+ DeployAngel.configure do |config|
231
+ config.recurring_jobs = {
232
+ "NightlyInvoiceJob" => "0 3 * * *", # bin/rails runner "NightlyInvoiceJob.perform_now"
233
+ "rake invoices:send" => "every day at 4am", # bin/rails invoices:send
234
+ "nightly import" => "30 2 * * *" # DeployAngel.task("nightly import") { ... }
235
+ }
236
+ end
237
+ ```
238
+
239
+ - **A job class** is recorded wherever it runs, a one-off `rails runner`
240
+ included.
241
+ - **A rake task** named `"rake <task>"` is recorded with no code change, as
242
+ long as it loads the app (depends on `:environment`).
243
+ - **Any other code** is recorded when you wrap it, which returns the block's
244
+ value and re-raises what it raises:
245
+
246
+ ```ruby
247
+ DeployAngel.task("nightly import") { Importer.run }
248
+ ```
249
+
250
+ The [whenever](https://github.com/javan/whenever) gem's `config/schedule.rb`
251
+ is read automatically when whenever is in the app's bundle (it's usually in
252
+ the Gemfile already, for deploys): its `rake` jobs, and `runner` jobs that
253
+ perform a job class (`"NightlyInvoiceJob.perform_later"`), are expected on
254
+ the schedule whenever writes to the crontab. Other `runner` code and
255
+ `command` jobs can't be matched to a run; wrap them in `DeployAngel.task`
256
+ and declare them as above.
257
+
213
258
  ## Checkpoints
214
259
 
215
260
  Errors and latency don't catch work that silently stops happening. Count the
@@ -11,7 +11,8 @@ module DeployAngel
11
11
 
12
12
  attr_accessor :token, :endpoint, :enabled, :environments, :release_version, :revision,
13
13
  :flush_interval, :open_timeout, :read_timeout, :max_queued_payloads, :max_routes, :logger,
14
- :file_digests, :critical_flows, :ignored_routes, :sidekiq_cron_schedule_file, :exception_messages
14
+ :file_digests, :critical_flows, :ignored_routes, :sidekiq_cron_schedule_file, :exception_messages,
15
+ :recurring_jobs
15
16
 
16
17
  def initialize(env = ENV)
17
18
  @token = env["DEPLOYANGEL_TOKEN"]
@@ -36,6 +37,12 @@ module DeployAngel
36
37
  # For an app that loads sidekiq-cron's jobs itself (load_from_hash!)
37
38
  # from its own file, such as "config/sidekiq_schedule.yml.erb".
38
39
  @sidekiq_cron_schedule_file = nil
40
+ # Work scheduled outside the app's own scheduler (cron, Heroku
41
+ # Scheduler, a Kubernetes CronJob), by the name it runs under: a job
42
+ # class, "rake <task>", or a DeployAngel.task name. Each schedule is a
43
+ # cron line or Fugit's words ("every day at 4am"), optionally ending
44
+ # in a time zone.
45
+ @recurring_jobs = {}
39
46
  end
40
47
 
41
48
  # Reports only with a token, and by default in every environment but
@@ -63,7 +63,73 @@ module DeployAngel
63
63
  # Declared recurring jobs. Each source is read on its own, so a file that
64
64
  # can't be read leaves the others' schedules in place.
65
65
  def schedules
66
- sidekiq_cron_schedules + sidekiq_scheduler_schedules
66
+ sidekiq_cron_schedules + sidekiq_scheduler_schedules + whenever_schedules + configured_schedules
67
+ end
68
+
69
+ # config.recurring_jobs: work scheduled outside the app, by the name it
70
+ # runs under. A schedule without a zone is read in the server's.
71
+ def configured_schedules
72
+ Array(@config.recurring_jobs).filter_map do |name, schedule|
73
+ next if name.to_s.strip.empty? || schedule.to_s.strip.empty?
74
+
75
+ { "key" => name.to_s, "class" => name.to_s, "schedule" => schedule.to_s, "source" => "config", "time_zone" => local_time_zone }
76
+ end
77
+ rescue StandardError
78
+ []
79
+ end
80
+
81
+ WHENEVER_FILE = "config/schedule.rb"
82
+ WHENEVER_JOB = /\A\s*(?:::)?([A-Z]\w*(?:::[A-Z]\w*)*)(?:\.set\(.*?\))?\.(?:perform_now|perform_later|perform_async|perform_inline)\b/m
83
+
84
+ # The whenever gem's config/schedule.rb, which it turns into the server's
85
+ # crontab on deploy. Read with whenever's own parser, so times come out
86
+ # as cron reads them, in the server's zone. Rake tasks are recorded by
87
+ # name (Tasks.install_rake); a runner that performs a job class is
88
+ # matched by that class. Other commands can't be told apart, so they're
89
+ # left to config.recurring_jobs and DeployAngel.task.
90
+ def whenever_schedules
91
+ path = File.join(@root, WHENEVER_FILE)
92
+ return [] unless File.file?(path) && whenever_loaded?
93
+
94
+ list = ::Whenever::JobList.new(file: path)
95
+ chronic = list.instance_variable_get(:@chronic_options) || {}
96
+ by_time = (list.instance_variable_get(:@jobs) || {}).values.flat_map(&:to_a)
97
+ by_time.flat_map do |time, jobs|
98
+ Array(jobs).flat_map { |job| whenever_entries(time, job, chronic) }
99
+ end.uniq { |schedule| schedule["key"] }
100
+ rescue StandardError, ScriptError
101
+ []
102
+ end
103
+
104
+ def whenever_loaded?
105
+ require "whenever"
106
+ true
107
+ rescue LoadError
108
+ false
109
+ end
110
+
111
+ def whenever_entries(time, job, chronic)
112
+ name = whenever_job_name(job) or return []
113
+ crons = ::Whenever::Output::Cron.enumerate(time).flat_map do |each|
114
+ ::Whenever::Output::Cron.enumerate(job.at, false).map do |at|
115
+ ::Whenever::Output::Cron.new(each, nil, at, chronic_options: chronic).time_in_cron_syntax.to_s
116
+ end
117
+ end.uniq.reject { |cron| cron.empty? || cron == "@reboot" }
118
+ crons.map do |cron|
119
+ { "key" => crons.one? ? name : "#{name} (#{cron})", "class" => name, "schedule" => cron, "source" => "whenever",
120
+ "time_zone" => local_time_zone }
121
+ end
122
+ end
123
+
124
+ def whenever_job_name(job)
125
+ template = job.instance_variable_get(:@template).to_s
126
+ task = job.instance_variable_get(:@options)&.dig(:task).to_s.strip
127
+ if template.match?(/\brake :task\b/)
128
+ rake = task.split.first.to_s
129
+ "#{Tasks::RAKE_PREFIX}#{rake}" unless rake.empty?
130
+ elsif template.include?(":runner_command")
131
+ task[WHENEVER_JOB, 1]
132
+ end
67
133
  end
68
134
 
69
135
  # sidekiq-cron jobs from its schedule file (config/schedule.yml unless
@@ -3,7 +3,7 @@
3
3
  module DeployAngel
4
4
  module Rails
5
5
  # The Rails adapter: the route table from the router, job classes from
6
- # Active Job, and Solid Queue's recurring tasks. The schedule files
6
+ # Active Job, and Solid Queue's and GoodJob's recurring tasks. The schedule files
7
7
  # sidekiq-cron and sidekiq-scheduler read, and the file digests, are
8
8
  # gathered in DeployAngel::Metadata.
9
9
  class Metadata < ::DeployAngel::Metadata
@@ -44,10 +44,10 @@ module DeployAngel
44
44
  []
45
45
  end
46
46
 
47
- # Solid Queue's schedule is Rails' own; the rest are read the same way
48
- # on every framework.
47
+ # Solid Queue's and GoodJob's schedules live in the Rails app; the rest
48
+ # are read the same way on every framework.
49
49
  def schedules
50
- solid_queue_schedules + super
50
+ solid_queue_schedules + good_job_schedules + super
51
51
  end
52
52
 
53
53
  # Solid Queue recurring tasks for the current environment.
@@ -69,6 +69,33 @@ module DeployAngel
69
69
  []
70
70
  end
71
71
 
72
+ # GoodJob's cron, from a process that runs it. GoodJob turns cron on
73
+ # per process (often only the worker's, with GOOD_JOB_ENABLE_CRON), and
74
+ # its CLI applies --enable-cron after boot, before metadata is first
75
+ # sent. A schedule without a zone is read in the server's, as Fugit
76
+ # reads it. A schedule or class given as a lambda can't be known
77
+ # without running app code, so it's left out.
78
+ def good_job_schedules
79
+ return [] unless defined?(::GoodJob) && ::GoodJob.respond_to?(:configuration)
80
+
81
+ configuration = ::GoodJob.configuration
82
+ return [] unless configuration.enable_cron?
83
+
84
+ configuration.cron.filter_map do |key, entry|
85
+ next unless entry.is_a?(Hash)
86
+
87
+ entry = entry.transform_keys(&:to_sym)
88
+ cron, job_class = entry.values_at(:cron, :class)
89
+ job_class = job_class.name if job_class.is_a?(Module)
90
+ next if entry[:enabled_by_default] == false
91
+ next unless cron.is_a?(String) && !cron.strip.empty? && job_class.is_a?(String) && !job_class.empty?
92
+
93
+ { "key" => key.to_s, "class" => job_class, "schedule" => cron, "source" => "good_job", "time_zone" => local_time_zone }
94
+ end
95
+ rescue StandardError
96
+ []
97
+ end
98
+
72
99
  # A command task runs as a job of this class, so its runs show up in
73
100
  # job telemetry under that name.
74
101
  def command_job_class
@@ -24,14 +24,24 @@ module DeployAngel
24
24
  DeployAngel::Apartment.install if DeployAngel.configuration.exception_messages
25
25
  # The metadata goes in at start, before the reporter thread can run:
26
26
  # the code fingerprint is built from its file digests.
27
+ metadata = DeployAngel::Rails::Metadata.new(app: app, config: DeployAngel.configuration, root: ::Rails.root.to_s, environment: ::Rails.env)
27
28
  DeployAngel.start(
28
29
  environment: ::Rails.env,
29
30
  root: ::Rails.root.to_s,
30
31
  framework: "rails",
31
32
  framework_version: ::Rails.version,
32
33
  logger: ::Rails.logger,
33
- metadata: DeployAngel::Rails::Metadata.new(app: app, config: DeployAngel.configuration, root: ::Rails.root.to_s, environment: ::Rails.env)
34
+ metadata: metadata
34
35
  )
36
+ # A rake task cron starts loads the app (its :environment
37
+ # prerequisite) before it runs, so the hook is in place in time.
38
+ DeployAngel::Tasks.install_rake(metadata.schedules) if DeployAngel.recording? && Railtie.rake_process?
39
+ end
40
+
41
+ def self.rake_process?
42
+ defined?(::Rake.application) && ::Rake.application.top_level_tasks.any?
43
+ rescue StandardError
44
+ false
35
45
  end
36
46
  end
37
47
  end
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "set"
4
+
5
+ module DeployAngel
6
+ # Work that runs on a schedule but isn't a job, such as a rake task that
7
+ # cron, Heroku Scheduler, or whenever starts. Recorded as a job run under
8
+ # its name, with its duration and whether it raised, so DeployAngel can
9
+ # expect it on schedule like any recurring job.
10
+ module Tasks
11
+ NAME = /\A[[:graph:]][[:print:]]{0,199}\z/
12
+ RAKE_PREFIX = "rake "
13
+
14
+ module_function
15
+
16
+ def run(name)
17
+ name = name.to_s
18
+ return yield unless DeployAngel.recording? && valid?(name)
19
+
20
+ DeployAngel.add_capability("jobs")
21
+ previous = ExecutionContext.enter(ExecutionContext::JOB)
22
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
23
+ failed = false
24
+ begin
25
+ yield
26
+ rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
27
+ failed = true
28
+ DeployAngel.record_exception(e, source: "job_class:#{name}")
29
+ raise
30
+ ensure
31
+ ExecutionContext.restore(previous)
32
+ DeployAngel.record_job(job_class: name, duration_ms: (Process.clock_gettime(Process::CLOCK_MONOTONIC) - started) * 1000.0,
33
+ failed: failed)
34
+ end
35
+ end
36
+
37
+ def valid?(name)
38
+ return true if name.match?(NAME)
39
+
40
+ unless @warned
41
+ DeployAngel.configuration.logger&.warn("DeployAngel didn't record task #{name.inspect}: use a name of up to 200 printable characters")
42
+ @warned = true
43
+ end
44
+ false
45
+ end
46
+
47
+ # Records the rake tasks a schedule names ("rake invoices:send"), so a
48
+ # task cron starts needs no code change. Only those tasks: others, like
49
+ # db:migrate at deploy time, aren't recurring work.
50
+ def install_rake(schedules)
51
+ return unless defined?(::Rake::Task)
52
+
53
+ names = Array(schedules).filter_map { |schedule| schedule["class"].to_s.delete_prefix(RAKE_PREFIX) if schedule["class"].to_s.start_with?(RAKE_PREFIX) }
54
+ return if names.empty?
55
+
56
+ @rake_tasks = names.to_set
57
+ ::Rake::Task.prepend(RakeTask) unless ::Rake::Task <= RakeTask
58
+ rescue StandardError
59
+ nil
60
+ end
61
+
62
+ def scheduled_rake_task?(name)
63
+ @rake_tasks&.include?(name) || false
64
+ end
65
+
66
+ module RakeTask
67
+ def execute(args = nil)
68
+ return super unless DeployAngel::Tasks.scheduled_rake_task?(name)
69
+
70
+ DeployAngel::Tasks.run("#{DeployAngel::Tasks::RAKE_PREFIX}#{name}") { super }
71
+ end
72
+ end
73
+ end
74
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.21"
4
+ VERSION = "0.1.23"
5
5
  end
data/lib/deployangel.rb CHANGED
@@ -61,6 +61,15 @@ module DeployAngel
61
61
  agent&.record_exception(exception, **options)
62
62
  end
63
63
 
64
+ # Records a block as a run of scheduled work, for code that cron or a
65
+ # scheduler outside the app starts and that isn't a job:
66
+ # DeployAngel.task("invoices:send") { Invoice.send_due }
67
+ # It returns the block's value and re-raises what it raises. Declare the
68
+ # schedule in config.recurring_jobs so a missed run is noticed.
69
+ def task(name, &)
70
+ Tasks.run(name, &)
71
+ end
72
+
64
73
  CHECKPOINT_NAME = /\A[a-z0-9][a-z0-9_.:-]{0,99}\z/i
65
74
 
66
75
  # Counts a business event, such as DeployAngel.checkpoint("order.fulfilled").
@@ -116,6 +125,7 @@ require_relative "deployangel/rack/http"
116
125
  require_relative "deployangel/rails/http"
117
126
  require_relative "deployangel/rails/active_job"
118
127
  require_relative "deployangel/rails/error_subscriber"
128
+ require_relative "deployangel/tasks"
119
129
  require_relative "deployangel/metadata"
120
130
  require_relative "deployangel/rails/metadata"
121
131
  require_relative "deployangel/sidekiq"
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.21
4
+ version: 0.1.23
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Owens
@@ -57,6 +57,7 @@ files:
57
57
  - lib/deployangel/rails/metadata.rb
58
58
  - lib/deployangel/rails/railtie.rb
59
59
  - lib/deployangel/sidekiq.rb
60
+ - lib/deployangel/tasks.rb
60
61
  - lib/deployangel/verification_waiter.rb
61
62
  - lib/deployangel/version.rb
62
63
  homepage: https://www.deployangel.com
@@ -83,7 +84,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
83
84
  - !ruby/object:Gem::Version
84
85
  version: '0'
85
86
  requirements: []
86
- rubygems_version: 4.0.10
87
+ rubygems_version: 4.0.0
87
88
  specification_version: 4
88
89
  summary: 'DeployAngel agent for Rails: production verification for every deployment.'
89
90
  test_files: []