deployangel 0.1.14 → 0.1.15

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: ff074e43a43cb39394f9c21d99c1c004c55f68ac7c5a54467acc49b4f0ce1753
4
- data.tar.gz: 57f860c0f680e70f46f12756f3f638307cb13ec6aa4131e5ebaac0e1cfe16494
3
+ metadata.gz: fae02d0fd8337fc6eb82ef0492d1bab86d03071dd9c9774dc332e95a6f52ffc9
4
+ data.tar.gz: 733fce2015cfa6e8fd6f5c618005cdda6c5aabf6aae8d7be128a156c46be65a6
5
5
  SHA512:
6
- metadata.gz: 10d7c45fac648fedae41d360d845b3f874be6026bd331e77d063eaf65a036b4f0ba616a0c74df6e18ede073df87789fee71ac84910902d8456306c2ccb3bb0af
7
- data.tar.gz: b68c68f332fe45d48be53f3d47e0b5c8bd41b012adf9deb6fc654e4ba9489ed20c3b4b790f6a860723f962ea8774b11082f0a1ce4f0c2f74bb9379b55d0937bf
6
+ metadata.gz: db2bd88f8bb149df827631c8a44453c8a328c790b6174a370d39469c1e025055bfb5923c70f52984b8a1a6f2e702e79a17c600f176375dc053f31c390f7ebae8
7
+ data.tar.gz: 2e33938b4d09aa6bd342300e2097ad7a10fe8a3a722296acd5e06a6e5fc9f39a9c6f16364907abb159dcfe02c95f659e0df5096e3c51f9334240b1cce50f0faa
data/CHANGELOG.md CHANGED
@@ -1,5 +1,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.15 (2026-10-06)
4
+
5
+ - Checkpoints report whether they were recorded in an HTTP request or a job
6
+ (`http` and `job` counts on each checkpoint), so DeployAngel compares a
7
+ checkpoint recorded in jobs against job traffic rather than requests. A job
8
+ performed inline during a request counts as a job. Needs a DeployAngel
9
+ server that reads the new fields; older servers ignore them.
10
+
3
11
  ## 0.1.14 (2026-10-06)
4
12
 
5
13
  - `deployangel plan` separates what clearance waits on ("Needed to clear")
data/README.md CHANGED
@@ -207,6 +207,10 @@ and job still succeeds. Only drops are flagged, and a checkpoint without enough
207
207
  traffic never blocks a release from being cleared. Checkpoints can also be part
208
208
  of a critical flow (`checkpoint:order.created`).
209
209
 
210
+ The agent also records whether each checkpoint was counted during an HTTP
211
+ request or a background job, so DeployAngel compares it against the right
212
+ traffic: a checkpoint counted in jobs is judged against jobs, not requests.
213
+
210
214
  It's safe to call anywhere: it never raises, never touches the network, and is
211
215
  ignored outside reporting environments. Names use letters, numbers, and
212
216
  `. _ : -` (up to 100 characters); keep them to a fixed set rather than
@@ -71,12 +71,14 @@ module DeployAngel
71
71
  warn_once(:record_discard, "DeployAngel failed to record a discarded job: #{e.class}: #{e.message}")
72
72
  end
73
73
 
74
- def record_checkpoint(name, count: 1)
74
+ # context defaults to the unit of work this fiber is running: an HTTP
75
+ # request, a job, or neither.
76
+ def record_checkpoint(name, count: 1, context: ExecutionContext.current)
75
77
  return unless @active
76
78
 
77
79
  after_fork! if Process.pid != @pid
78
80
  start_reporter
79
- @aggregator.record_checkpoint(name: name, count: count)
81
+ @aggregator.record_checkpoint(name: name, count: count, context: context)
80
82
  rescue StandardError => e
81
83
  warn_once(:record_checkpoint, "DeployAngel failed to record a checkpoint: #{e.class}: #{e.message}")
82
84
  end
@@ -37,6 +37,20 @@ module DeployAngel
37
37
  end
38
38
  end
39
39
 
40
+ # count: every recording; http and job: those recorded while handling an
41
+ # HTTP request or running a job (see ExecutionContext). The rest were
42
+ # recorded outside either.
43
+ CheckpointStats = Struct.new(:count, :http, :job) do
44
+ def record(count, context)
45
+ self.count += count
46
+ if context == ExecutionContext::HTTP
47
+ self.http += count
48
+ elsif context == ExecutionContext::JOB
49
+ self.job += count
50
+ end
51
+ end
52
+ end
53
+
40
54
  class Period
41
55
  attr_reader :started_at, :requests, :status_counts, :unhandled_exceptions, :histogram, :routes,
42
56
  :jobs, :job_classes, :exceptions, :exceptions_truncated, :checkpoints
@@ -52,13 +66,13 @@ module DeployAngel
52
66
  @job_classes = {}
53
67
  @exceptions = {}
54
68
  @exceptions_truncated = 0
55
- @checkpoints = Hash.new(0)
69
+ @checkpoints = {}
56
70
  end
57
71
 
58
72
  # Up to 100 names per period; the rest are counted together.
59
- def record_checkpoint(name, count)
73
+ def record_checkpoint(name, count, context)
60
74
  key = @checkpoints.key?(name) || @checkpoints.size < MAX_CHECKPOINTS - 1 ? name : OTHER_ROUTE
61
- @checkpoints[key] += count
75
+ (@checkpoints[key] ||= CheckpointStats.new(0, 0, 0)).record(count, context)
62
76
  end
63
77
 
64
78
  # Up to 20 fingerprints per period; the rest are only counted.
@@ -141,9 +155,11 @@ module DeployAngel
141
155
  @mutex.synchronize { (@periods[started_at] ||= Period.new(started_at)).record_discard(job_class.to_s, @max_routes) }
142
156
  end
143
157
 
144
- def record_checkpoint(name:, count: 1)
158
+ # context: where it was recorded, ExecutionContext::HTTP, ExecutionContext::JOB,
159
+ # or nil for neither.
160
+ def record_checkpoint(name:, count: 1, context: nil)
145
161
  started_at = period_start(@clock.call)
146
- @mutex.synchronize { (@periods[started_at] ||= Period.new(started_at)).record_checkpoint(name, count) }
162
+ @mutex.synchronize { (@periods[started_at] ||= Period.new(started_at)).record_checkpoint(name, count, context) }
147
163
  end
148
164
 
149
165
  # A representative backtrace is kept only the first time this process
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ module DeployAngel
4
+ # The unit of work the current fiber is running: HTTP while the request
5
+ # middleware handles a request, JOB while job instrumentation runs a job,
6
+ # and nil otherwise (console, rake tasks, boot, a thread of the app's own).
7
+ # Checkpoints read it to say where they were recorded.
8
+ #
9
+ # Thread.current[] is fiber-local, like Rails' isolated execution state, so
10
+ # requests served concurrently in threads (Puma) or fibers (Falcon) each
11
+ # see their own. Every integration restores the previous value when its
12
+ # unit ends, even when it raises, so the innermost unit wins when they nest
13
+ # (a job performed inline during a request), and nothing leaks into the
14
+ # next request a reused thread serves.
15
+ module ExecutionContext
16
+ KEY = :__deployangel_execution_context
17
+ HTTP = :http
18
+ JOB = :job
19
+
20
+ module_function
21
+
22
+ def current
23
+ Thread.current[KEY]
24
+ end
25
+
26
+ # Returns the previous context, to hand back to restore.
27
+ def enter(kind)
28
+ previous = Thread.current[KEY]
29
+ Thread.current[KEY] = kind
30
+ previous
31
+ end
32
+
33
+ def restore(previous)
34
+ Thread.current[KEY] = previous
35
+ end
36
+ end
37
+ end
@@ -34,7 +34,11 @@ module DeployAngel
34
34
  "exceptions_truncated" => period.exceptions_truncated,
35
35
  "jobs" => job_stats(period.jobs),
36
36
  "job_classes" => period.job_classes.map { |key, stats| { "key" => key }.merge(job_stats(stats)) },
37
- "checkpoints" => period.checkpoints.map { |key, count| { "key" => key, "count" => count } }
37
+ # http and job are always sent, 0 when none, so the cloud can tell
38
+ # this agent from one that doesn't say where checkpoints happened.
39
+ "checkpoints" => period.checkpoints.map do |key, stats|
40
+ { "key" => key, "count" => stats.count, "http" => stats.http, "job" => stats.job }
41
+ end
38
42
  }
39
43
  end
40
44
 
@@ -31,11 +31,16 @@ module DeployAngel
31
31
 
32
32
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
33
33
  Redaction.request_host = env["HTTP_HOST"]
34
+ # Checkpoints recorded from here until the app returns count as
35
+ # recorded in a request.
36
+ previous_context = ExecutionContext.enter(ExecutionContext::HTTP)
34
37
  begin
35
38
  status, headers, body = @app.call(env)
36
39
  rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
37
40
  record(env, 500, started, unhandled: true, exception: e)
38
41
  raise
42
+ ensure
43
+ ExecutionContext.restore(previous_context)
39
44
  end
40
45
  # A framework that renders an exception itself swallows it before it
41
46
  # reaches this middleware, and renders some as 4xx; only those that
@@ -11,6 +11,34 @@ module DeployAngel
11
11
  # perform finishes.
12
12
  module ActiveJob
13
13
  STATE = :@__deployangel_job_state
14
+ PREVIOUS_CONTEXT = :@__deployangel_previous_context
15
+
16
+ # Marks the fiber as running a job for as long as perform.active_job
17
+ # lasts, so checkpoints recorded by the job say so. ActiveSupport calls
18
+ # finish from an ensure, so the previous context (an HTTP request, for
19
+ # a job performed inline) comes back even when the job raises. It is
20
+ # kept on the job, so only a perform that started here restores.
21
+ module ContextListener
22
+ module_function
23
+
24
+ def start(_name, _id, payload)
25
+ job = payload[:job]
26
+ return unless job && DeployAngel.recording?
27
+
28
+ job.instance_variable_set(PREVIOUS_CONTEXT, ExecutionContext.enter(ExecutionContext::JOB))
29
+ rescue StandardError
30
+ nil
31
+ end
32
+
33
+ def finish(_name, _id, payload)
34
+ job = payload[:job]
35
+ return unless job&.instance_variable_defined?(PREVIOUS_CONTEXT)
36
+
37
+ ExecutionContext.restore(job.remove_instance_variable(PREVIOUS_CONTEXT))
38
+ rescue StandardError
39
+ nil
40
+ end
41
+ end
14
42
 
15
43
  module_function
16
44
 
@@ -23,6 +51,7 @@ module DeployAngel
23
51
  subscribe("enqueue_retry.active_job") { |payload| state(payload).merge!(failed: true, error: payload[:error]) }
24
52
  subscribe("discard.active_job") { |payload| state(payload).merge!(failed: true, discarded: true, error: payload[:error]) }
25
53
  subscribe("retry_stopped.active_job") { |payload| state(payload).merge!(failed: true, discarded: true, error: payload[:error]) }
54
+ ::ActiveSupport::Notifications.subscribe("perform.active_job", ContextListener)
26
55
  ::ActiveSupport::Notifications.subscribe("perform.active_job") { |event| record(event) }
27
56
  end
28
57
 
@@ -16,12 +16,15 @@ module DeployAngel
16
16
 
17
17
  started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
18
18
  latency = DeployAngel::Sidekiq.queue_latency_ms(job)
19
+ previous_context = ExecutionContext.enter(ExecutionContext::JOB)
19
20
  begin
20
21
  yield
21
22
  rescue Exception => e # rubocop:disable Lint/RescueException -- recorded, then re-raised untouched
22
23
  DeployAngel.record_exception(e, source: "job_class:#{job["class"]}")
23
24
  record(job, started, latency, failed: true)
24
25
  raise
26
+ ensure
27
+ ExecutionContext.restore(previous_context)
25
28
  end
26
29
  record(job, started, latency, failed: false)
27
30
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module DeployAngel
4
- VERSION = "0.1.14"
4
+ VERSION = "0.1.15"
5
5
  end
data/lib/deployangel.rb CHANGED
@@ -9,6 +9,7 @@ require_relative "deployangel/core/release"
9
9
  require_relative "deployangel/core/instance"
10
10
  require_relative "deployangel/core/aggregator"
11
11
  require_relative "deployangel/core/protocol"
12
+ require_relative "deployangel/core/execution_context"
12
13
  require_relative "deployangel/core/buffer"
13
14
  require_relative "deployangel/core/transport"
14
15
  require_relative "deployangel/agent"
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.14
4
+ version: 0.1.15
5
5
  platform: ruby
6
6
  authors:
7
7
  - Jordan Owens
@@ -38,6 +38,7 @@ files:
38
38
  - lib/deployangel/configuration.rb
39
39
  - lib/deployangel/core/aggregator.rb
40
40
  - lib/deployangel/core/buffer.rb
41
+ - lib/deployangel/core/execution_context.rb
41
42
  - lib/deployangel/core/fingerprint.rb
42
43
  - lib/deployangel/core/histogram.rb
43
44
  - lib/deployangel/core/instance.rb