chronos-ruby 0.4.0.pre.1 → 0.6.0.pre.1
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 +33 -9
- data/contracts/event-v1.schema.json +25 -11
- data/contracts/sidekiq-job-v1.schema.json +21 -0
- data/docs/adr/ADR-013-legacy-rails-notifications.md +27 -0
- data/docs/adr/ADR-014-sidekiq-envelope-context.md +27 -0
- data/docs/architecture.md +12 -1
- data/docs/compatibility.md +12 -6
- data/docs/configuration.md +8 -2
- data/docs/data-collected.md +14 -2
- data/docs/modules/rails-legacy.md +60 -0
- data/docs/modules/sidekiq-legacy.md +23 -0
- data/docs/modules/telemetry-events.md +9 -0
- data/docs/performance.md +27 -3
- data/docs/privacy-lgpd.md +12 -3
- data/docs/troubleshooting.md +17 -1
- data/lib/chronos/agent.rb +58 -1
- data/lib/chronos/application/capture_telemetry.rb +37 -0
- data/lib/chronos/application/remote_configuration.rb +1 -1
- data/lib/chronos/configuration/snapshot.rb +53 -0
- data/lib/chronos/configuration/validation.rb +122 -0
- data/lib/chronos/configuration.rb +15 -153
- data/lib/chronos/core/telemetry_event.rb +106 -0
- data/lib/chronos/integrations/job_payload.rb +72 -0
- data/lib/chronos/integrations/rack/middleware.rb +5 -1
- data/lib/chronos/integrations/sidekiq.rb +258 -0
- data/lib/chronos/integrations.rb +1 -1
- data/lib/chronos/internal/worker_pool.rb +19 -2
- data/lib/chronos/rails/installer.rb +70 -0
- data/lib/chronos/rails/notifications_subscriber.rb +203 -0
- data/lib/chronos/rails/railtie.rb +21 -0
- data/lib/chronos/rails.rb +16 -0
- data/lib/chronos/sidekiq.rb +12 -0
- data/lib/chronos/version.rb +1 -1
- data/lib/chronos.rb +34 -1
- data/lib/generators/chronos/install/install_generator.rb +25 -0
- data/lib/generators/chronos/install/templates/chronos.rb +20 -0
- metadata +21 -2
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Integrations
|
|
3
|
+
# Builds bounded job metadata before the shared privacy serializer runs.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Limit job arguments, tags, strings, containers, and nesting.
|
|
6
|
+
# @motivation Worker payloads are application-controlled and may be very large.
|
|
7
|
+
# @limits It limits values but deliberately leaves redaction to Core::Sanitizer.
|
|
8
|
+
# @collaborators Sidekiq middleware and the Chronos telemetry pipeline.
|
|
9
|
+
# @thread_safety Instances keep no mutable state and may be shared.
|
|
10
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
11
|
+
# @example
|
|
12
|
+
# JobPayload.new.arguments(["account-1"])
|
|
13
|
+
# @errors Unreadable values become bounded class-name placeholders.
|
|
14
|
+
# @performance Traversal is capped by argument, collection, depth, and string limits.
|
|
15
|
+
class JobPayload
|
|
16
|
+
MAX_ARGUMENTS = 20
|
|
17
|
+
MAX_COLLECTION_ITEMS = 20
|
|
18
|
+
MAX_DEPTH = 4
|
|
19
|
+
MAX_STRING_BYTES = 512
|
|
20
|
+
|
|
21
|
+
def arguments(values)
|
|
22
|
+
source = values.is_a?(Array) ? values : []
|
|
23
|
+
[source.first(MAX_ARGUMENTS).map { |value| limit(value, 0) }, source.length > MAX_ARGUMENTS]
|
|
24
|
+
rescue StandardError
|
|
25
|
+
[[], true]
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def tags(values)
|
|
29
|
+
Array(values).first(MAX_COLLECTION_ITEMS).map { |value| limit_string(value.to_s) }
|
|
30
|
+
rescue StandardError
|
|
31
|
+
[]
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def limit(value, depth)
|
|
37
|
+
return "[TRUNCATED]" if depth >= MAX_DEPTH
|
|
38
|
+
|
|
39
|
+
case value
|
|
40
|
+
when Hash
|
|
41
|
+
limit_hash(value, depth)
|
|
42
|
+
when Array
|
|
43
|
+
value.first(MAX_COLLECTION_ITEMS).map { |child| limit(child, depth + 1) }
|
|
44
|
+
when String
|
|
45
|
+
limit_string(value)
|
|
46
|
+
when NilClass, TrueClass, FalseClass, Numeric
|
|
47
|
+
value
|
|
48
|
+
else
|
|
49
|
+
limit_string(value.to_s)
|
|
50
|
+
end
|
|
51
|
+
rescue StandardError
|
|
52
|
+
"[UNREADABLE]"
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def limit_hash(value, depth)
|
|
56
|
+
result = {}
|
|
57
|
+
value.to_a.first(MAX_COLLECTION_ITEMS).each do |key, child|
|
|
58
|
+
result[limit_string(key.to_s)] = limit(child, depth + 1)
|
|
59
|
+
end
|
|
60
|
+
result
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def limit_string(value)
|
|
64
|
+
return value if value.bytesize <= MAX_STRING_BYTES
|
|
65
|
+
|
|
66
|
+
value.byteslice(0, MAX_STRING_BYTES) + "[TRUNCATED]"
|
|
67
|
+
rescue StandardError
|
|
68
|
+
"[UNREADABLE]"
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -47,7 +47,11 @@ module Chronos
|
|
|
47
47
|
end
|
|
48
48
|
|
|
49
49
|
def notify_safely(error, context)
|
|
50
|
-
@notifier.
|
|
50
|
+
if @notifier.respond_to?(:notify_once)
|
|
51
|
+
@notifier.notify_once(error, context)
|
|
52
|
+
else
|
|
53
|
+
@notifier.notify(error, context)
|
|
54
|
+
end
|
|
51
55
|
rescue StandardError
|
|
52
56
|
false
|
|
53
57
|
end
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
require "securerandom"
|
|
2
|
+
require "chronos/integrations/job_payload"
|
|
3
|
+
|
|
4
|
+
module Chronos
|
|
5
|
+
module Integrations
|
|
6
|
+
# Optional Sidekiq 4/5 middleware integration for the legacy Chronos line.
|
|
7
|
+
#
|
|
8
|
+
# @responsibility Install client/server middleware and namespace worker telemetry.
|
|
9
|
+
# @motivation Sidekiq jobs need process-boundary context and failure capture.
|
|
10
|
+
# @limits It does not own Sidekiq lifecycle, Redis connections, retries, or threads.
|
|
11
|
+
# @collaborators Sidekiq public middleware configuration and Chronos facade.
|
|
12
|
+
# @thread_safety Installation is mutex-protected; middleware instances are stateless.
|
|
13
|
+
# @compatibility Sidekiq 4.x and 5.x; Ruby 2.2.10 through Ruby 2.6.
|
|
14
|
+
# @example
|
|
15
|
+
# Chronos::Integrations::Sidekiq.install
|
|
16
|
+
# @errors Missing Sidekiq or configuration failures return false.
|
|
17
|
+
# @performance Adds bounded payload normalization and two clock reads per performed job.
|
|
18
|
+
module Sidekiq
|
|
19
|
+
CONTEXT_KEY = "chronos".freeze
|
|
20
|
+
CONTEXT_SCHEMA_VERSION = "1.0".freeze
|
|
21
|
+
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
@installed = false
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
def install(sidekiq = nil, notifier = Chronos)
|
|
27
|
+
library = sidekiq || (::Sidekiq if defined?(::Sidekiq))
|
|
28
|
+
return false unless library
|
|
29
|
+
|
|
30
|
+
@mutex.synchronize do
|
|
31
|
+
return false if @installed
|
|
32
|
+
|
|
33
|
+
configure_client(library, notifier)
|
|
34
|
+
configure_server(library, notifier)
|
|
35
|
+
@installed = true
|
|
36
|
+
end
|
|
37
|
+
true
|
|
38
|
+
rescue StandardError
|
|
39
|
+
false
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def configure_client(library, notifier)
|
|
45
|
+
return unless library.respond_to?(:configure_client)
|
|
46
|
+
|
|
47
|
+
library.configure_client do |config|
|
|
48
|
+
next unless config.respond_to?(:client_middleware)
|
|
49
|
+
|
|
50
|
+
config.client_middleware { |chain| chain.add(ClientMiddleware, :notifier => notifier) }
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def configure_server(library, notifier)
|
|
55
|
+
return unless library.respond_to?(:configure_server)
|
|
56
|
+
|
|
57
|
+
library.configure_server do |config|
|
|
58
|
+
if config.respond_to?(:client_middleware)
|
|
59
|
+
config.client_middleware { |chain| chain.add(ClientMiddleware, :notifier => notifier) }
|
|
60
|
+
end
|
|
61
|
+
if config.respond_to?(:server_middleware)
|
|
62
|
+
config.server_middleware { |chain| chain.add(ServerMiddleware, :notifier => notifier) }
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Adds a small allowlisted Chronos context to the Sidekiq job envelope.
|
|
69
|
+
#
|
|
70
|
+
# @responsibility Propagate trace/request identifiers without modifying `args`.
|
|
71
|
+
# @motivation Client and server usually execute in different processes.
|
|
72
|
+
# @limits It does not capture arguments, enqueue errors, or application context fields.
|
|
73
|
+
# @collaborators Chronos propagation context and Sidekiq client middleware chain.
|
|
74
|
+
# @thread_safety Calls allocate their own hashes and may execute concurrently.
|
|
75
|
+
# @compatibility Sidekiq 4.x/5.x client middleware signature.
|
|
76
|
+
# @example
|
|
77
|
+
# ClientMiddleware.new.call(MyWorker, job, "default") { push(job) }
|
|
78
|
+
# @errors Context failures are contained and the enqueue chain still runs.
|
|
79
|
+
# @performance Adds one bounded hash to the job payload; opens no connection or thread.
|
|
80
|
+
class ClientMiddleware
|
|
81
|
+
def initialize(options = {})
|
|
82
|
+
@notifier = options[:notifier] || Chronos
|
|
83
|
+
@clock = options[:clock] || proc { Time.now.to_f }
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def call(_worker_class, job, _queue, _redis_pool = nil)
|
|
87
|
+
add_context(job)
|
|
88
|
+
yield
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
private
|
|
92
|
+
|
|
93
|
+
def add_context(job)
|
|
94
|
+
return unless job.is_a?(Hash)
|
|
95
|
+
|
|
96
|
+
context = propagation_context
|
|
97
|
+
context["trace_id"] = SecureRandom.uuid if context["trace_id"].to_s.empty?
|
|
98
|
+
job[CONTEXT_KEY] = {
|
|
99
|
+
"schema_version" => CONTEXT_SCHEMA_VERSION,
|
|
100
|
+
"enqueued_at" => @clock.call.to_f,
|
|
101
|
+
"context" => context
|
|
102
|
+
}
|
|
103
|
+
rescue StandardError
|
|
104
|
+
nil
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def propagation_context
|
|
108
|
+
return {} unless @notifier.respond_to?(:propagation_context)
|
|
109
|
+
|
|
110
|
+
source = @notifier.propagation_context
|
|
111
|
+
return {} unless source.is_a?(Hash)
|
|
112
|
+
|
|
113
|
+
%w(trace_id request_id).each_with_object({}) do |key, result|
|
|
114
|
+
value = source[key] || source[key.to_sym]
|
|
115
|
+
result[key] = value.to_s unless value.to_s.empty?
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Captures Sidekiq execution timing and failures around the worker call.
|
|
121
|
+
#
|
|
122
|
+
# @responsibility Scope propagated context, emit one job event, notify failures once, and re-raise.
|
|
123
|
+
# @motivation Worker failures otherwise lose queue metadata and request correlation.
|
|
124
|
+
# @limits It does not change retry behavior, acknowledge jobs, or install global error handlers.
|
|
125
|
+
# @collaborators Chronos facade, JobPayload, and Sidekiq server middleware chain.
|
|
126
|
+
# @thread_safety Shared instances keep only immutable collaborators.
|
|
127
|
+
# @compatibility Sidekiq 4.x/5.x server middleware signature.
|
|
128
|
+
# @example
|
|
129
|
+
# ServerMiddleware.new.call(worker, job, "default") { worker.perform }
|
|
130
|
+
# @errors The original worker exception is always re-raised after contained notification.
|
|
131
|
+
# @performance No per-job thread or connection; normalization has strict collection limits.
|
|
132
|
+
class ServerMiddleware
|
|
133
|
+
def initialize(options = {})
|
|
134
|
+
@notifier = options[:notifier] || Chronos
|
|
135
|
+
@clock = options[:clock] || proc { monotonic_time }
|
|
136
|
+
@wall_clock = options[:wall_clock] || proc { Time.now.to_f }
|
|
137
|
+
@limiter = options[:limiter] || JobPayload.new
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def call(worker, job, queue)
|
|
141
|
+
started_at = @clock.call
|
|
142
|
+
payload = base_payload(worker, job, queue)
|
|
143
|
+
context = execution_context(job, payload)
|
|
144
|
+
@notifier.with_context(context) do
|
|
145
|
+
begin
|
|
146
|
+
result = yield
|
|
147
|
+
finish(payload, started_at, "completed")
|
|
148
|
+
result
|
|
149
|
+
rescue Exception => error # rubocop:disable Lint/RescueException
|
|
150
|
+
finish(payload, started_at, "failed", error)
|
|
151
|
+
notify_failure(error, payload)
|
|
152
|
+
raise
|
|
153
|
+
end
|
|
154
|
+
end
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
private
|
|
158
|
+
|
|
159
|
+
def base_payload(worker, job, queue)
|
|
160
|
+
source = job.is_a?(Hash) ? job : {}
|
|
161
|
+
arguments, truncated = @limiter.arguments(source["args"] || source[:args])
|
|
162
|
+
payload = {
|
|
163
|
+
"kind" => "sidekiq", "class" => worker_class(worker, source),
|
|
164
|
+
"queue" => (source["queue"] || source[:queue] || queue).to_s,
|
|
165
|
+
"jid" => (source["jid"] || source[:jid]).to_s,
|
|
166
|
+
"retry_count" => retry_count(source), "arguments" => arguments,
|
|
167
|
+
"arguments_truncated" => truncated, "tags" => tags(worker, source),
|
|
168
|
+
"queue_latency_ms" => queue_latency(source)
|
|
169
|
+
}
|
|
170
|
+
payload
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def execution_context(job, payload)
|
|
174
|
+
metadata = job.is_a?(Hash) ? (job[CONTEXT_KEY] || job[CONTEXT_KEY.to_sym]) : nil
|
|
175
|
+
propagated = metadata.is_a?(Hash) ? (metadata["context"] || metadata[:context]) : nil
|
|
176
|
+
propagated = {} unless propagated.is_a?(Hash)
|
|
177
|
+
{
|
|
178
|
+
:context => propagated.merge("job" => job_context(payload)),
|
|
179
|
+
:__chronos_captured_exceptions => {}
|
|
180
|
+
}
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
def finish(payload, started_at, status, error = nil)
|
|
184
|
+
payload["duration_ms"] = [elapsed_ms(started_at), 0.0].max
|
|
185
|
+
payload["status"] = status
|
|
186
|
+
payload["error_class"] = error.class.name.to_s if error
|
|
187
|
+
@notifier.record_event("job", payload)
|
|
188
|
+
rescue StandardError
|
|
189
|
+
false
|
|
190
|
+
end
|
|
191
|
+
|
|
192
|
+
def notify_failure(error, payload)
|
|
193
|
+
context = {:context => {"job" => job_context(payload)},
|
|
194
|
+
:parameters => {"arguments" => payload["arguments"]}, :tags => payload["tags"]}
|
|
195
|
+
if @notifier.respond_to?(:notify_once)
|
|
196
|
+
@notifier.notify_once(error, context)
|
|
197
|
+
else
|
|
198
|
+
@notifier.notify(error, context)
|
|
199
|
+
end
|
|
200
|
+
rescue StandardError
|
|
201
|
+
false
|
|
202
|
+
end
|
|
203
|
+
|
|
204
|
+
def job_context(payload)
|
|
205
|
+
%w(kind class queue jid retry_count queue_latency_ms duration_ms status).each_with_object({}) do |key, result|
|
|
206
|
+
result[key] = payload[key] unless payload[key].nil?
|
|
207
|
+
end
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def worker_class(worker, job)
|
|
211
|
+
explicit = job["wrapped"] || job[:wrapped] || job["class"] || job[:class]
|
|
212
|
+
explicit = worker.class.name if explicit.to_s.empty? && worker
|
|
213
|
+
explicit.to_s
|
|
214
|
+
rescue StandardError
|
|
215
|
+
""
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def retry_count(job)
|
|
219
|
+
value = job["retry_count"] || job[:retry_count]
|
|
220
|
+
value.to_i < 0 ? 0 : value.to_i
|
|
221
|
+
end
|
|
222
|
+
|
|
223
|
+
def tags(worker, job)
|
|
224
|
+
values = job["tags"] || job[:tags]
|
|
225
|
+
if values.nil? && worker
|
|
226
|
+
owner = worker.is_a?(Class) ? worker : worker.class
|
|
227
|
+
options = owner.get_sidekiq_options if owner.respond_to?(:get_sidekiq_options)
|
|
228
|
+
values = options["tags"] || options[:tags] if options.is_a?(Hash)
|
|
229
|
+
end
|
|
230
|
+
@limiter.tags(values)
|
|
231
|
+
rescue StandardError
|
|
232
|
+
[]
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
def queue_latency(job)
|
|
236
|
+
metadata = job[CONTEXT_KEY] || job[CONTEXT_KEY.to_sym]
|
|
237
|
+
enqueued_at = job["enqueued_at"] || job[:enqueued_at]
|
|
238
|
+
enqueued_at ||= metadata["enqueued_at"] || metadata[:enqueued_at] if metadata.is_a?(Hash)
|
|
239
|
+
return nil unless enqueued_at
|
|
240
|
+
|
|
241
|
+
[((@wall_clock.call.to_f - enqueued_at.to_f) * 1000.0).round(3), 0.0].max
|
|
242
|
+
rescue StandardError
|
|
243
|
+
nil
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def elapsed_ms(started_at)
|
|
247
|
+
((@clock.call - started_at) * 1000.0).round(3)
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def monotonic_time
|
|
251
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
252
|
+
rescue StandardError
|
|
253
|
+
Time.now.to_f
|
|
254
|
+
end
|
|
255
|
+
end
|
|
256
|
+
end
|
|
257
|
+
end
|
|
258
|
+
end
|
data/lib/chronos/integrations.rb
CHANGED
|
@@ -5,6 +5,6 @@ module Chronos
|
|
|
5
5
|
# @motivation Keep framework loading optional for plain Ruby applications.
|
|
6
6
|
# @limits Integrations may depend only on documented public agent behavior.
|
|
7
7
|
# @thread_safety Each integration documents its own guarantees.
|
|
8
|
-
# @compatibility Version 0.
|
|
8
|
+
# @compatibility Version 0.5 supports Rack protocol behavior on Ruby 2.2.10 through 2.6.
|
|
9
9
|
module Integrations; end
|
|
10
10
|
end
|
|
@@ -24,6 +24,7 @@ module Chronos
|
|
|
24
24
|
@mutex = Mutex.new
|
|
25
25
|
@threads = []
|
|
26
26
|
@active = 0
|
|
27
|
+
@pending = 0
|
|
27
28
|
@closed = false
|
|
28
29
|
@pid = Process.pid
|
|
29
30
|
end
|
|
@@ -32,7 +33,9 @@ module Chronos
|
|
|
32
33
|
prepare_after_fork
|
|
33
34
|
return false if closed?
|
|
34
35
|
|
|
36
|
+
increment_pending
|
|
35
37
|
accepted = @queue.push(event)
|
|
38
|
+
decrement_pending unless accepted
|
|
36
39
|
ensure_started if accepted
|
|
37
40
|
accepted
|
|
38
41
|
end
|
|
@@ -42,7 +45,7 @@ module Chronos
|
|
|
42
45
|
ensure_started unless @queue.empty?
|
|
43
46
|
deadline = monotonic_time + timeout.to_f
|
|
44
47
|
loop do
|
|
45
|
-
return true if
|
|
48
|
+
return true if delivery_complete?
|
|
46
49
|
return false if monotonic_time >= deadline
|
|
47
50
|
sleep(POLL_INTERVAL)
|
|
48
51
|
end
|
|
@@ -91,6 +94,7 @@ module Chronos
|
|
|
91
94
|
@logger.warn("Chronos worker contained #{error.class}")
|
|
92
95
|
ensure
|
|
93
96
|
decrement_active
|
|
97
|
+
decrement_pending
|
|
94
98
|
end
|
|
95
99
|
end
|
|
96
100
|
rescue StandardError => error
|
|
@@ -104,13 +108,14 @@ module Chronos
|
|
|
104
108
|
@pid = Process.pid
|
|
105
109
|
@threads = []
|
|
106
110
|
@active = 0
|
|
111
|
+
@pending = @queue.size
|
|
107
112
|
end
|
|
108
113
|
end
|
|
109
114
|
|
|
110
115
|
def flush_without_reopening(timeout)
|
|
111
116
|
deadline = monotonic_time + timeout.to_f
|
|
112
117
|
loop do
|
|
113
|
-
return true if
|
|
118
|
+
return true if delivery_complete?
|
|
114
119
|
return false if monotonic_time >= deadline
|
|
115
120
|
sleep(POLL_INTERVAL)
|
|
116
121
|
end
|
|
@@ -138,6 +143,18 @@ module Chronos
|
|
|
138
143
|
@mutex.synchronize { @active }
|
|
139
144
|
end
|
|
140
145
|
|
|
146
|
+
def increment_pending
|
|
147
|
+
@mutex.synchronize { @pending += 1 }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
def decrement_pending
|
|
151
|
+
@mutex.synchronize { @pending -= 1 }
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def delivery_complete?
|
|
155
|
+
@mutex.synchronize { @pending.zero? && @active.zero? }
|
|
156
|
+
end
|
|
157
|
+
|
|
141
158
|
def closed?
|
|
142
159
|
@mutex.synchronize { @closed }
|
|
143
160
|
end
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Rails
|
|
3
|
+
# Installs Rails middleware and subscribers exactly once per application.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Apply feature-detected integration hooks after Rails initializers load.
|
|
6
|
+
# @motivation Centralize version-neutral installation outside Railtie DSL code.
|
|
7
|
+
# @limits It does not configure credentials or depend on private Rails APIs.
|
|
8
|
+
# @collaborators Rails application middleware, NotificationsSubscriber, and Chronos facade.
|
|
9
|
+
# @thread_safety A mutex protects the per-application installation registry.
|
|
10
|
+
# @compatibility Rails 4.2 through Rails 5.2 without Zeitwerk.
|
|
11
|
+
# @example
|
|
12
|
+
# Chronos::Rails::Installer.new.install(Rails.application)
|
|
13
|
+
# @errors Missing optional Rails capabilities return false without affecting boot.
|
|
14
|
+
# @performance Installation is one-time; request work is delegated to bounded integrations.
|
|
15
|
+
class Installer
|
|
16
|
+
@mutex = Mutex.new
|
|
17
|
+
@applications = {}
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
attr_reader :mutex, :applications
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def initialize(notifier = Chronos, subscriber = nil)
|
|
24
|
+
@notifier = notifier
|
|
25
|
+
@subscriber = subscriber || NotificationsSubscriber.new(notifier)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def install(application)
|
|
29
|
+
options = @notifier.rails_integration_options(environment, console?)
|
|
30
|
+
return false unless options[:enabled]
|
|
31
|
+
|
|
32
|
+
self.class.mutex.synchronize do
|
|
33
|
+
return false if self.class.applications[application.object_id]
|
|
34
|
+
|
|
35
|
+
install_middleware(application, options)
|
|
36
|
+
@subscriber.install
|
|
37
|
+
self.class.applications[application.object_id] = true
|
|
38
|
+
end
|
|
39
|
+
true
|
|
40
|
+
rescue StandardError
|
|
41
|
+
false
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def rails_version
|
|
45
|
+
defined?(::Rails) && ::Rails.respond_to?(:version) ? ::Rails.version.to_s : "unknown"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def install_middleware(application, options)
|
|
51
|
+
middleware = application.config.middleware
|
|
52
|
+
return false unless middleware.respond_to?(:use)
|
|
53
|
+
|
|
54
|
+
middleware.use(
|
|
55
|
+
Chronos::Integrations::Rack::Middleware,
|
|
56
|
+
:include_user_agent => options[:include_user_agent]
|
|
57
|
+
)
|
|
58
|
+
true
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def environment
|
|
62
|
+
defined?(::Rails) && ::Rails.respond_to?(:env) ? ::Rails.env.to_s : nil
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def console?
|
|
66
|
+
defined?(::Rails::Console)
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Rails
|
|
3
|
+
# Converts public ActiveSupport notifications into bounded Chronos telemetry.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Subscribe once and normalize controller, view, SQL, mailer, job, and cache events.
|
|
6
|
+
# @motivation Support Rails 4.2 and 5.2 through feature detection and public notification APIs.
|
|
7
|
+
# @limits It never sends SQL text, bind values, cache keys, mail bodies, or job arguments.
|
|
8
|
+
# @collaborators ActiveSupport::Notifications and the Chronos facade.
|
|
9
|
+
# @thread_safety Subscription registry is mutex-protected; callbacks own their state.
|
|
10
|
+
# @compatibility ActiveSupport notification argument shapes from Rails 4.2 through 5.2.
|
|
11
|
+
# @example
|
|
12
|
+
# Chronos::Rails::NotificationsSubscriber.new.install
|
|
13
|
+
# @errors Subscriber failures are contained and never escape into Rails.
|
|
14
|
+
# @performance Each notification builds a small allowlisted hash and queues asynchronously.
|
|
15
|
+
class NotificationsSubscriber
|
|
16
|
+
EVENTS = %w(
|
|
17
|
+
process_action.action_controller render_template.action_view sql.active_record
|
|
18
|
+
deliver.action_mailer perform.active_job cache_read.active_support
|
|
19
|
+
cache_write.active_support cache_fetch_hit.active_support
|
|
20
|
+
).freeze
|
|
21
|
+
|
|
22
|
+
@mutex = Mutex.new
|
|
23
|
+
@installed_buses = {}
|
|
24
|
+
|
|
25
|
+
class << self
|
|
26
|
+
attr_reader :mutex, :installed_buses
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def initialize(notifier = Chronos, notifications = nil)
|
|
30
|
+
@notifier = notifier
|
|
31
|
+
@notifications = notifications || active_support_notifications
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def install
|
|
35
|
+
return false unless @notifications && @notifications.respond_to?(:subscribe)
|
|
36
|
+
|
|
37
|
+
self.class.mutex.synchronize do
|
|
38
|
+
return false if self.class.installed_buses[@notifications.object_id]
|
|
39
|
+
|
|
40
|
+
event_names.each { |name| subscribe(name) }
|
|
41
|
+
self.class.installed_buses[@notifications.object_id] = true
|
|
42
|
+
end
|
|
43
|
+
true
|
|
44
|
+
rescue StandardError
|
|
45
|
+
false
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def handle(name, arguments)
|
|
49
|
+
event = notification_event(name, arguments)
|
|
50
|
+
dispatch(name, event[:payload], event[:duration_ms])
|
|
51
|
+
rescue StandardError
|
|
52
|
+
false
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
def active_support_notifications
|
|
58
|
+
return nil unless defined?(::ActiveSupport::Notifications)
|
|
59
|
+
|
|
60
|
+
::ActiveSupport::Notifications
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def subscribe(name)
|
|
64
|
+
@notifications.subscribe(name) { |*arguments| handle(name, arguments) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def event_names
|
|
68
|
+
return EVENTS if defined?(::ActiveJob)
|
|
69
|
+
|
|
70
|
+
EVENTS.reject { |name| name == "perform.active_job" }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def notification_event(name, arguments)
|
|
74
|
+
candidate = arguments.first
|
|
75
|
+
if arguments.length == 1 && candidate.respond_to?(:payload)
|
|
76
|
+
return {:payload => hash(candidate.payload), :duration_ms => candidate.duration.to_f}
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
started_at = arguments[1]
|
|
80
|
+
finished_at = arguments[2]
|
|
81
|
+
{
|
|
82
|
+
:payload => hash(arguments[4]),
|
|
83
|
+
:duration_ms => duration_ms(started_at, finished_at),
|
|
84
|
+
:name => name
|
|
85
|
+
}
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def dispatch(name, payload, duration)
|
|
89
|
+
case name
|
|
90
|
+
when "process_action.action_controller" then process_action(payload, duration)
|
|
91
|
+
when "render_template.action_view" then render_template(payload, duration)
|
|
92
|
+
when "sql.active_record" then sql(payload, duration)
|
|
93
|
+
when "deliver.action_mailer" then mailer(payload, duration)
|
|
94
|
+
when "perform.active_job" then active_job(payload, duration)
|
|
95
|
+
else cache(name, payload, duration)
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
def process_action(payload, duration)
|
|
100
|
+
capture_controller_exception(payload)
|
|
101
|
+
data = {
|
|
102
|
+
"kind" => "controller", "controller" => value(payload, :controller),
|
|
103
|
+
"action" => value(payload, :action), "status" => value(payload, :status).to_i,
|
|
104
|
+
"method" => value(payload, :method), "path" => query_free_path(value(payload, :path)),
|
|
105
|
+
"route" => route(payload),
|
|
106
|
+
"duration_ms" => duration, "parameters" => hash(value(payload, :params))
|
|
107
|
+
}
|
|
108
|
+
@notifier.record_event("request", data)
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def render_template(payload, duration)
|
|
112
|
+
data = {
|
|
113
|
+
"kind" => "view", "template" => safe_basename(value(payload, :identifier)),
|
|
114
|
+
"duration_ms" => duration
|
|
115
|
+
}
|
|
116
|
+
@notifier.record_event("request", data)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def sql(payload, duration)
|
|
120
|
+
data = {
|
|
121
|
+
"name" => value(payload, :name).to_s, "cached" => value(payload, :cached) == true,
|
|
122
|
+
"duration_ms" => duration
|
|
123
|
+
}
|
|
124
|
+
@notifier.record_event("query", data)
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def mailer(payload, duration)
|
|
128
|
+
data = {
|
|
129
|
+
"kind" => "mailer", "mailer" => value(payload, :mailer).to_s,
|
|
130
|
+
"action" => value(payload, :action).to_s, "duration_ms" => duration
|
|
131
|
+
}
|
|
132
|
+
@notifier.record_event("job", data)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def active_job(payload, duration)
|
|
136
|
+
job = value(payload, :job)
|
|
137
|
+
data = {
|
|
138
|
+
"kind" => "active_job", "class" => safe_class_name(job),
|
|
139
|
+
"queue" => safe_job_value(job, :queue_name), "duration_ms" => duration
|
|
140
|
+
}
|
|
141
|
+
@notifier.record_event("job", data)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def cache(name, payload, duration)
|
|
145
|
+
data = {
|
|
146
|
+
"operation" => name.split(".").first, "store" => value(payload, :store).to_s,
|
|
147
|
+
"hit" => value(payload, :hit), "duration_ms" => duration
|
|
148
|
+
}
|
|
149
|
+
@notifier.record_event("cache", data)
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
def capture_controller_exception(payload)
|
|
153
|
+
exception = value(payload, :exception_object)
|
|
154
|
+
details = value(payload, :exception)
|
|
155
|
+
exception ||= RuntimeError.new(Array(details).last.to_s) if details
|
|
156
|
+
@notifier.notify_once(exception, :parameters => hash(value(payload, :params))) if exception
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def value(payload, key)
|
|
160
|
+
payload.key?(key) ? payload[key] : payload[key.to_s]
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
def hash(value)
|
|
164
|
+
value.is_a?(Hash) ? value : {}
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def duration_ms(started_at, finished_at)
|
|
168
|
+
return 0.0 unless started_at && finished_at
|
|
169
|
+
|
|
170
|
+
((finished_at.to_f - started_at.to_f) * 1000.0).round(3)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def query_free_path(path)
|
|
174
|
+
path.to_s.split("?", 2).first
|
|
175
|
+
end
|
|
176
|
+
|
|
177
|
+
def route(payload)
|
|
178
|
+
explicit = value(payload, :route)
|
|
179
|
+
return explicit.to_s unless explicit.to_s.empty?
|
|
180
|
+
|
|
181
|
+
[value(payload, :controller), value(payload, :action)].compact.join("#")
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def safe_basename(identifier)
|
|
185
|
+
File.basename(identifier.to_s)
|
|
186
|
+
rescue StandardError
|
|
187
|
+
"<template>"
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def safe_class_name(object)
|
|
191
|
+
object ? object.class.name.to_s : ""
|
|
192
|
+
rescue StandardError
|
|
193
|
+
"Object"
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def safe_job_value(job, method_name)
|
|
197
|
+
job.respond_to?(method_name) ? job.public_send(method_name).to_s : ""
|
|
198
|
+
rescue StandardError
|
|
199
|
+
""
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|