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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +22 -0
  3. data/README.md +33 -9
  4. data/contracts/event-v1.schema.json +25 -11
  5. data/contracts/sidekiq-job-v1.schema.json +21 -0
  6. data/docs/adr/ADR-013-legacy-rails-notifications.md +27 -0
  7. data/docs/adr/ADR-014-sidekiq-envelope-context.md +27 -0
  8. data/docs/architecture.md +12 -1
  9. data/docs/compatibility.md +12 -6
  10. data/docs/configuration.md +8 -2
  11. data/docs/data-collected.md +14 -2
  12. data/docs/modules/rails-legacy.md +60 -0
  13. data/docs/modules/sidekiq-legacy.md +23 -0
  14. data/docs/modules/telemetry-events.md +9 -0
  15. data/docs/performance.md +27 -3
  16. data/docs/privacy-lgpd.md +12 -3
  17. data/docs/troubleshooting.md +17 -1
  18. data/lib/chronos/agent.rb +58 -1
  19. data/lib/chronos/application/capture_telemetry.rb +37 -0
  20. data/lib/chronos/application/remote_configuration.rb +1 -1
  21. data/lib/chronos/configuration/snapshot.rb +53 -0
  22. data/lib/chronos/configuration/validation.rb +122 -0
  23. data/lib/chronos/configuration.rb +15 -153
  24. data/lib/chronos/core/telemetry_event.rb +106 -0
  25. data/lib/chronos/integrations/job_payload.rb +72 -0
  26. data/lib/chronos/integrations/rack/middleware.rb +5 -1
  27. data/lib/chronos/integrations/sidekiq.rb +258 -0
  28. data/lib/chronos/integrations.rb +1 -1
  29. data/lib/chronos/internal/worker_pool.rb +19 -2
  30. data/lib/chronos/rails/installer.rb +70 -0
  31. data/lib/chronos/rails/notifications_subscriber.rb +203 -0
  32. data/lib/chronos/rails/railtie.rb +21 -0
  33. data/lib/chronos/rails.rb +16 -0
  34. data/lib/chronos/sidekiq.rb +12 -0
  35. data/lib/chronos/version.rb +1 -1
  36. data/lib/chronos.rb +34 -1
  37. data/lib/generators/chronos/install/install_generator.rb +25 -0
  38. data/lib/generators/chronos/install/templates/chronos.rb +20 -0
  39. 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.notify(error, context)
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
@@ -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.4 supports Rack protocol behavior on Ruby 2.2.10 through 2.6.
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 @queue.empty? && active_count.zero?
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 @queue.empty? && active_count.zero?
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