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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +46 -0
- data/README.md +37 -8
- data/contracts/apm-batch-v1.schema.json +35 -0
- data/contracts/event-v1.schema.json +1 -1
- data/contracts/sidekiq-job-v1.schema.json +21 -0
- data/docs/adr/ADR-014-sidekiq-envelope-context.md +27 -0
- data/docs/adr/ADR-015-bounded-apm-aggregation.md +27 -0
- data/docs/architecture.md +10 -1
- data/docs/compatibility.md +8 -0
- data/docs/configuration.md +18 -1
- data/docs/data-collected.md +16 -2
- data/docs/modules/apm-aggregation.md +57 -0
- data/docs/modules/configuration.md +2 -0
- data/docs/modules/rails-legacy.md +1 -1
- data/docs/modules/sidekiq-legacy.md +23 -0
- data/docs/modules/telemetry-events.md +1 -1
- data/docs/performance.md +25 -1
- data/docs/privacy-lgpd.md +13 -3
- data/docs/troubleshooting.md +20 -0
- data/lib/chronos/agent.rb +40 -0
- data/lib/chronos/application/apm_aggregator.rb +273 -0
- data/lib/chronos/application/capture_telemetry.rb +45 -5
- data/lib/chronos/application/delivery_pipeline.rb +7 -0
- data/lib/chronos/application/remote_configuration.rb +6 -1
- data/lib/chronos/configuration/apm_validation.rb +60 -0
- data/lib/chronos/configuration.rb +22 -2
- data/lib/chronos/core/metric_aggregate.rb +113 -0
- data/lib/chronos/core/sql_normalizer.rb +114 -0
- data/lib/chronos/core/telemetry_event.rb +1 -1
- data/lib/chronos/integrations/job_payload.rb +72 -0
- data/lib/chronos/integrations/rack/middleware.rb +19 -1
- data/lib/chronos/integrations/sidekiq.rb +260 -0
- data/lib/chronos/internal/worker_pool.rb +19 -2
- data/lib/chronos/rails/notifications_subscriber.rb +36 -4
- data/lib/chronos/sidekiq.rb +12 -0
- data/lib/chronos/version.rb +1 -1
- data/lib/chronos.rb +25 -0
- 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
|
-
|
|
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
|
|
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
|