chronos-ruby 0.5.0.pre.1 → 0.7.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 +46 -0
  3. data/README.md +37 -8
  4. data/contracts/apm-batch-v1.schema.json +35 -0
  5. data/contracts/event-v1.schema.json +1 -1
  6. data/contracts/sidekiq-job-v1.schema.json +21 -0
  7. data/docs/adr/ADR-014-sidekiq-envelope-context.md +27 -0
  8. data/docs/adr/ADR-015-bounded-apm-aggregation.md +27 -0
  9. data/docs/architecture.md +10 -1
  10. data/docs/compatibility.md +8 -0
  11. data/docs/configuration.md +18 -1
  12. data/docs/data-collected.md +16 -2
  13. data/docs/modules/apm-aggregation.md +57 -0
  14. data/docs/modules/configuration.md +2 -0
  15. data/docs/modules/rails-legacy.md +1 -1
  16. data/docs/modules/sidekiq-legacy.md +23 -0
  17. data/docs/modules/telemetry-events.md +1 -1
  18. data/docs/performance.md +25 -1
  19. data/docs/privacy-lgpd.md +13 -3
  20. data/docs/troubleshooting.md +20 -0
  21. data/lib/chronos/agent.rb +40 -0
  22. data/lib/chronos/application/apm_aggregator.rb +273 -0
  23. data/lib/chronos/application/capture_telemetry.rb +45 -5
  24. data/lib/chronos/application/delivery_pipeline.rb +7 -0
  25. data/lib/chronos/application/remote_configuration.rb +6 -1
  26. data/lib/chronos/configuration/apm_validation.rb +60 -0
  27. data/lib/chronos/configuration.rb +22 -2
  28. data/lib/chronos/core/metric_aggregate.rb +113 -0
  29. data/lib/chronos/core/sql_normalizer.rb +114 -0
  30. data/lib/chronos/core/telemetry_event.rb +1 -1
  31. data/lib/chronos/integrations/job_payload.rb +72 -0
  32. data/lib/chronos/integrations/rack/middleware.rb +19 -1
  33. data/lib/chronos/integrations/sidekiq.rb +260 -0
  34. data/lib/chronos/internal/worker_pool.rb +19 -2
  35. data/lib/chronos/rails/notifications_subscriber.rb +36 -4
  36. data/lib/chronos/sidekiq.rb +12 -0
  37. data/lib/chronos/version.rb +1 -1
  38. data/lib/chronos.rb +25 -0
  39. metadata +15 -2
@@ -0,0 +1,113 @@
1
+ module Chronos
2
+ module Core
3
+ # Accumulates one bounded APM metric group and serializes aggregate statistics.
4
+ #
5
+ # @responsibility Track counts, errors, durations, histogram, breakdown, and signals.
6
+ # @motivation Keep numerical accumulation separate from grouping and request correlation.
7
+ # @limits It does not choose dimensions, retain observations, or calculate percentiles.
8
+ # @collaborators ApmAggregator and immutable histogram boundaries.
9
+ # @thread_safety Mutable by design; callers must synchronize access.
10
+ # @compatibility Ruby 2.2.10 through Ruby 2.6.
11
+ # @example
12
+ # metric.observe(12.0, false, {"database" => 3.0}, {})
13
+ # @errors Non-numeric durations become zero and never escape.
14
+ # @performance Memory is fixed by the configured histogram boundary count.
15
+ class MetricAggregate
16
+ BREAKDOWN_CATEGORIES = %w(database view external_http cache queue application unknown).freeze
17
+
18
+ def initialize(metric_type, dimensions, boundaries)
19
+ @metric_type = metric_type
20
+ @dimensions = dimensions
21
+ @boundaries = boundaries
22
+ @count = 0
23
+ @error_count = 0
24
+ @total = 0.0
25
+ @min = nil
26
+ @max = nil
27
+ @buckets = Array.new(boundaries.length + 1, 0)
28
+ @breakdown = {}
29
+ @signals = {}
30
+ @status_codes = {}
31
+ end
32
+
33
+ def observe(duration, error, breakdown, signals, status = nil)
34
+ value = non_negative(duration)
35
+ @count += 1
36
+ @error_count += 1 if error
37
+ @total += value
38
+ @min = value if @min.nil? || value < @min
39
+ @max = value if @max.nil? || value > @max
40
+ bucket = @boundaries.index { |boundary| value <= boundary }
41
+ @buckets[bucket || @boundaries.length] += 1
42
+ add_breakdown(breakdown)
43
+ add_signals(signals)
44
+ add_status(status)
45
+ self
46
+ end
47
+
48
+ def to_h
49
+ {
50
+ "metric_type" => @metric_type, "dimensions" => @dimensions,
51
+ "count" => @count, "error_count" => @error_count,
52
+ "error_rate" => (@error_count.to_f / @count).round(6),
53
+ "duration_ms" => duration_summary, "histogram" => histogram,
54
+ "breakdown_ms" => rounded_hash(@breakdown), "signals" => @signals,
55
+ "status_codes" => @status_codes
56
+ }
57
+ end
58
+
59
+ private
60
+
61
+ def add_breakdown(values)
62
+ hash(values).each do |category, duration|
63
+ name = BREAKDOWN_CATEGORIES.include?(category.to_s) ? category.to_s : "unknown"
64
+ @breakdown[name] ||= 0.0
65
+ @breakdown[name] += non_negative(duration)
66
+ end
67
+ end
68
+
69
+ def add_signals(values)
70
+ hash(values).each do |name, count|
71
+ @signals[name.to_s] ||= 0
72
+ @signals[name.to_s] += count.to_i
73
+ end
74
+ end
75
+
76
+ def add_status(status)
77
+ return if status.nil?
78
+
79
+ key = status.to_i.to_s
80
+ @status_codes[key] ||= 0
81
+ @status_codes[key] += 1
82
+ end
83
+
84
+ def duration_summary
85
+ {
86
+ "total" => @total.round(3), "min" => @min.round(3), "max" => @max.round(3),
87
+ "average" => (@total / @count).round(3)
88
+ }
89
+ end
90
+
91
+ def histogram
92
+ @buckets.each_with_index.map do |count, index|
93
+ {"le" => @boundaries[index] || "+Inf", "count" => count}
94
+ end
95
+ end
96
+
97
+ def rounded_hash(values)
98
+ values.each_with_object({}) { |(key, value), result| result[key] = value.round(3) }
99
+ end
100
+
101
+ def non_negative(value)
102
+ number = value.to_f
103
+ number < 0.0 ? 0.0 : number
104
+ rescue StandardError
105
+ 0.0
106
+ end
107
+
108
+ def hash(value)
109
+ value.is_a?(Hash) ? value : {}
110
+ end
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,114 @@
1
+ require "digest"
2
+
3
+ module Chronos
4
+ module Core
5
+ # Produces bounded, value-free SQL metadata for aggregation and local signals.
6
+ #
7
+ # @responsibility Remove comments/literals and derive operation, table, and fingerprint.
8
+ # @motivation SQL metrics need stable low-cardinality identity without bind values.
9
+ # @limits It is a defensive lexer, not a complete dialect-specific SQL parser.
10
+ # @collaborators Rails notification adapter and APM aggregator.
11
+ # @thread_safety Instances are stateless and safe to share.
12
+ # @compatibility Ruby 2.2.10 through Ruby 2.6; no ActiveRecord dependency.
13
+ # @example
14
+ # SqlNormalizer.new.call("SELECT * FROM users WHERE id = 42")
15
+ # @errors Malformed or unreadable input returns bounded unknown metadata.
16
+ # @performance Input and normalized output are capped before fingerprinting.
17
+ class SqlNormalizer
18
+ MAX_SQL_BYTES = 4096
19
+ MAX_NORMALIZED_BYTES = 512
20
+ KEYWORDS = %w(
21
+ select insert update delete from into where join left right inner outer on set values
22
+ and or order group by having limit offset as distinct begin commit rollback savepoint release
23
+ ).freeze
24
+
25
+ def call(sql, metadata = {})
26
+ normalized = normalize(sql)
27
+ {
28
+ "adapter" => adapter(metadata), "operation" => operation(normalized),
29
+ "table" => table(normalized), "normalized_query" => normalized,
30
+ "fingerprint" => Digest::SHA256.hexdigest(normalized),
31
+ "name" => value(metadata, :name).to_s,
32
+ "cached" => value(metadata, :cached) == true,
33
+ "role" => optional_string(metadata, :role, :connection_role),
34
+ "shard" => optional_string(metadata, :shard, :connection_shard),
35
+ "source" => bounded(value(metadata, :source).to_s, 256),
36
+ "error_class" => error_class(metadata)
37
+ }.delete_if { |_key, child| child.nil? || child == "" }
38
+ rescue StandardError
39
+ {"operation" => "UNKNOWN", "normalized_query" => "", "fingerprint" => Digest::SHA256.hexdigest("")}
40
+ end
41
+
42
+ private
43
+
44
+ def normalize(sql)
45
+ text = bounded(sql.to_s, MAX_SQL_BYTES)
46
+ text = text.gsub(%r{/\*.*?\*/}m, " ").gsub(/--[^\r\n]*/, " ")
47
+ text = text.gsub(/'(?:''|[^'])*'/, "?")
48
+ text = text.gsub(/\$([A-Za-z_][A-Za-z0-9_]*)?\$.*?\$\1\$/m, "?")
49
+ text = text.gsub(/\b(?:0x[0-9a-f]+|\d+(?:\.\d+)?)\b/i, "?")
50
+ text = text.gsub(/\b(?:true|false|null)\b/i, "?")
51
+ text = text.gsub(/\(\s*\?(?:\s*,\s*\?)+\s*\)/, "(?)")
52
+ text = text.gsub(/\s+/, " ").strip
53
+ KEYWORDS.each { |keyword| text.gsub!(/\b#{keyword}\b/i, keyword.upcase) }
54
+ bounded(text, MAX_NORMALIZED_BYTES)
55
+ end
56
+
57
+ def operation(normalized)
58
+ candidate = normalized.to_s.split(/\s+/, 2).first.to_s.upcase
59
+ candidate =~ /\A[A-Z]+\z/ ? candidate : "UNKNOWN"
60
+ end
61
+
62
+ def table(normalized)
63
+ match = normalized.match(/\b(?:FROM|INTO|UPDATE|JOIN)\s+["`\[]?([A-Za-z0-9_.-]+)/i)
64
+ bounded(match && match[1].to_s, 128)
65
+ end
66
+
67
+ def adapter(metadata)
68
+ direct = value(metadata, :adapter)
69
+ return bounded(direct.to_s, 64) unless direct.to_s.empty?
70
+
71
+ connection = value(metadata, :connection)
72
+ return "" unless connection && connection.respond_to?(:adapter_name)
73
+
74
+ bounded(connection.adapter_name.to_s, 64)
75
+ rescue StandardError
76
+ ""
77
+ end
78
+
79
+ def error_class(metadata)
80
+ error = value(metadata, :exception_object) || value(metadata, :exception)
81
+ return error.class.name.to_s if error.is_a?(Exception)
82
+ return Array(error).first.to_s unless error.nil?
83
+
84
+ ""
85
+ rescue StandardError
86
+ ""
87
+ end
88
+
89
+ def optional_string(metadata, *names)
90
+ names.each do |name|
91
+ candidate = value(metadata, name)
92
+ return bounded(candidate.to_s, 64) unless candidate.to_s.empty?
93
+ end
94
+ ""
95
+ end
96
+
97
+ def value(hash, key)
98
+ return nil unless hash.is_a?(Hash)
99
+
100
+ hash.key?(key) ? hash[key] : hash[key.to_s]
101
+ end
102
+
103
+ def bounded(value, limit)
104
+ text = value.to_s
105
+ text = text.scrub("?") if text.respond_to?(:scrub)
106
+ return text if text.bytesize <= limit
107
+
108
+ text.byteslice(0, limit)
109
+ rescue StandardError
110
+ ""
111
+ end
112
+ end
113
+ end
114
+ end
@@ -16,7 +16,7 @@ module Chronos
16
16
  # @errors Unsupported types raise ArgumentError during construction.
17
17
  # @performance Construction is linear in supplied context and payload size.
18
18
  class TelemetryEvent
19
- TYPES = %w(request query job cache).freeze
19
+ TYPES = %w(request query job cache metric_batch).freeze
20
20
 
21
21
  attr_reader :event_id, :event_type, :timestamp, :context, :payload
22
22
 
@@ -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
@@ -38,11 +38,14 @@ module Chronos
38
38
  response = @app.call(env)
39
39
  status = response[0]
40
40
  headers = response[1]
41
- add_request_breadcrumb("request completed", dynamic_request_context(base, status, headers, started_at))
41
+ context = dynamic_request_context(base, status, headers, started_at)
42
+ add_request_breadcrumb("request completed", context)
43
+ record_request_metric(context)
42
44
  response
43
45
  rescue Exception => error # rubocop:disable Lint/RescueException
44
46
  context = dynamic_request_context(base, 500, nil, started_at)
45
47
  notify_safely(error, context)
48
+ record_request_metric(context)
46
49
  raise
47
50
  end
48
51
 
@@ -141,6 +144,21 @@ module Chronos
141
144
  )
142
145
  end
143
146
 
147
+ def record_request_metric(context)
148
+ request = context[:context]["request"]
149
+ payload = {
150
+ "kind" => "rack", "route" => request["route"], "method" => request["method"],
151
+ "status" => request["status"], "duration_ms" => request["duration_ms"]
152
+ }
153
+ if @notifier.respond_to?(:record_event_once)
154
+ @notifier.record_event_once("request", "request", payload)
155
+ elsif @notifier.respond_to?(:record_event)
156
+ @notifier.record_event("request", payload)
157
+ end
158
+ rescue StandardError
159
+ false
160
+ end
161
+
144
162
  def hash_value(value)
145
163
  value.is_a?(Hash) ? value : {}
146
164
  end
@@ -0,0 +1,260 @@
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
+ :parameters => {"arguments" => payload["arguments"]},
180
+ :tags => payload["tags"],
181
+ :__chronos_captured_exceptions => {}
182
+ }
183
+ end
184
+
185
+ def finish(payload, started_at, status, error = nil)
186
+ payload["duration_ms"] = [elapsed_ms(started_at), 0.0].max
187
+ payload["status"] = status
188
+ payload["error_class"] = error.class.name.to_s if error
189
+ @notifier.record_event("job", payload)
190
+ rescue StandardError
191
+ false
192
+ end
193
+
194
+ def notify_failure(error, payload)
195
+ context = {:context => {"job" => job_context(payload)},
196
+ :parameters => {"arguments" => payload["arguments"]}, :tags => payload["tags"]}
197
+ if @notifier.respond_to?(:notify_once)
198
+ @notifier.notify_once(error, context)
199
+ else
200
+ @notifier.notify(error, context)
201
+ end
202
+ rescue StandardError
203
+ false
204
+ end
205
+
206
+ def job_context(payload)
207
+ %w(kind class queue jid retry_count queue_latency_ms duration_ms status).each_with_object({}) do |key, result|
208
+ result[key] = payload[key] unless payload[key].nil?
209
+ end
210
+ end
211
+
212
+ def worker_class(worker, job)
213
+ explicit = job["wrapped"] || job[:wrapped] || job["class"] || job[:class]
214
+ explicit = worker.class.name if explicit.to_s.empty? && worker
215
+ explicit.to_s
216
+ rescue StandardError
217
+ ""
218
+ end
219
+
220
+ def retry_count(job)
221
+ value = job["retry_count"] || job[:retry_count]
222
+ value.to_i < 0 ? 0 : value.to_i
223
+ end
224
+
225
+ def tags(worker, job)
226
+ values = job["tags"] || job[:tags]
227
+ if values.nil? && worker
228
+ owner = worker.is_a?(Class) ? worker : worker.class
229
+ options = owner.get_sidekiq_options if owner.respond_to?(:get_sidekiq_options)
230
+ values = options["tags"] || options[:tags] if options.is_a?(Hash)
231
+ end
232
+ @limiter.tags(values)
233
+ rescue StandardError
234
+ []
235
+ end
236
+
237
+ def queue_latency(job)
238
+ metadata = job[CONTEXT_KEY] || job[CONTEXT_KEY.to_sym]
239
+ enqueued_at = job["enqueued_at"] || job[:enqueued_at]
240
+ enqueued_at ||= metadata["enqueued_at"] || metadata[:enqueued_at] if metadata.is_a?(Hash)
241
+ return nil unless enqueued_at
242
+
243
+ [((@wall_clock.call.to_f - enqueued_at.to_f) * 1000.0).round(3), 0.0].max
244
+ rescue StandardError
245
+ nil
246
+ end
247
+
248
+ def elapsed_ms(started_at)
249
+ ((@clock.call - started_at) * 1000.0).round(3)
250
+ end
251
+
252
+ def monotonic_time
253
+ Process.clock_gettime(Process::CLOCK_MONOTONIC)
254
+ rescue StandardError
255
+ Time.now.to_f
256
+ end
257
+ end
258
+ end
259
+ end
260
+ 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