deployangel 0.1.21 → 0.1.22

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: edccb6c72eff7d259c14c59deaa403fb78c890cb091b40d59b080623945e1e9f
4
+ data.tar.gz: 364fd681c6199d591292fc67799cb6f7933da99736113ef382ff4ccf628697f8
5
5
  SHA512:
6
- metadata.gz: 4c18ca61ecff8936bdfa7f4a5900fd27b96c2a787094e504593fae78f62eaee3c7d4ea11e74811b619313c1a726bce8e4644f5a6319c687450ff81629acdd2a5
7
- data.tar.gz: 2754b3cc315c626df0dc57de4723721caa668f69b7b523a0ca4eaecaf4f84e2f2ec7f9f40f366be34f9a1989ff3c9b4650f1d02dc81b976218d2b3daf5e3968c
6
+ metadata.gz: 0ec79d7a8b20600e3fc57604b990749ff578a6b39bdd2caf945c8f60bf5f39ed74f3991659f8d3e26c851e8a7509cc4dabca339f36f35aaa79aca33648ba4c9d
7
+ data.tar.gz: 37db580510184b407f7941e6c27ad00eb956fab8df6fb59a68406753c14813bca7007de9da53ca7cf9a858f456d40f2a5da2fdd872c44bdd33997b052132ba2c
data/CHANGELOG.md CHANGED
@@ -1,5 +1,19 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.22 (2026-10-09)
4
+
5
+ - Work scheduled outside the app's own scheduler is watched too.
6
+ `config.recurring_jobs` declares it by the name it runs under (a job
7
+ class, `"rake <task>"`, or a `DeployAngel.task` name) with a cron line
8
+ or Fugit's words, for cron, Heroku Scheduler, or a Kubernetes CronJob.
9
+ - `DeployAngel.task("name") { ... }` records plain code that a scheduler
10
+ starts as a run of that name, with its duration and whether it raised.
11
+ - Rake tasks a schedule names are recorded with no code change, when the
12
+ task loads the app (`:environment`).
13
+ - The whenever gem's `config/schedule.rb` is read with whenever's own
14
+ parser, when whenever is in the bundle: rake tasks and runners that
15
+ perform a job class are expected on schedule, in the server's zone.
16
+
3
17
  ## 0.1.21 (2026-10-09)
4
18
 
5
19
  - `deployangel exercise` stops requesting a page after its first 404, 405,
data/README.md CHANGED
@@ -210,6 +210,43 @@ end
210
210
  Jobs that exist only in Redis, such as ones created in code or in the
211
211
  Sidekiq web UI, aren't read.
212
212
 
213
+ #### Cron, Heroku Scheduler, and whenever
214
+
215
+ Work that something outside the app starts on a schedule, such as cron,
216
+ Heroku Scheduler, or a Kubernetes CronJob, is invisible to those files.
217
+ Declare it by the name it runs under, with a cron line or words like
218
+ `"every day at 4am"`, in the server's time zone unless you add one
219
+ (`"0 3 * * * America/New_York"`):
220
+
221
+ ```ruby
222
+ DeployAngel.configure do |config|
223
+ config.recurring_jobs = {
224
+ "NightlyInvoiceJob" => "0 3 * * *", # bin/rails runner "NightlyInvoiceJob.perform_now"
225
+ "rake invoices:send" => "every day at 4am", # bin/rails invoices:send
226
+ "nightly import" => "30 2 * * *" # DeployAngel.task("nightly import") { ... }
227
+ }
228
+ end
229
+ ```
230
+
231
+ - **A job class** is recorded wherever it runs, a one-off `rails runner`
232
+ included.
233
+ - **A rake task** named `"rake <task>"` is recorded with no code change, as
234
+ long as it loads the app (depends on `:environment`).
235
+ - **Any other code** is recorded when you wrap it, which returns the block's
236
+ value and re-raises what it raises:
237
+
238
+ ```ruby
239
+ DeployAngel.task("nightly import") { Importer.run }
240
+ ```
241
+
242
+ The [whenever](https://github.com/javan/whenever) gem's `config/schedule.rb`
243
+ is read automatically when whenever is in the app's bundle (it's usually in
244
+ the Gemfile already, for deploys): its `rake` jobs, and `runner` jobs that
245
+ perform a job class (`"NightlyInvoiceJob.perform_later"`), are expected on
246
+ the schedule whenever writes to the crontab. Other `runner` code and
247
+ `command` jobs can't be matched to a run; wrap them in `DeployAngel.task`
248
+ and declare them as above.
249
+
213
250
  ## Checkpoints
214
251
 
215
252
  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
@@ -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.22"
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.22
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: []