chronos-ruby 0.7.0.pre.1 → 0.8.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 +24 -0
- data/README.md +33 -9
- data/contracts/apm-batch-v1.schema.json +1 -1
- data/contracts/dependencies-v1.schema.json +38 -0
- data/contracts/event-v1.schema.json +1 -1
- data/docs/adr/ADR-016-explicit-observability-integrations.md +25 -0
- data/docs/architecture.md +6 -1
- data/docs/compatibility.md +2 -0
- data/docs/configuration.md +12 -2
- data/docs/data-collected.md +9 -4
- data/docs/examples/plain-ruby.md +7 -1
- data/docs/modules/apm-aggregation.md +2 -2
- data/docs/modules/cache-observability.md +16 -0
- data/docs/modules/configuration.md +2 -0
- data/docs/modules/dependencies.md +18 -0
- data/docs/modules/external-http.md +27 -0
- data/docs/modules/rails-legacy.md +1 -1
- data/docs/modules/sidekiq-legacy.md +1 -1
- data/docs/modules/telemetry-events.md +2 -2
- data/docs/performance.md +16 -1
- data/docs/privacy-lgpd.md +11 -2
- data/docs/troubleshooting.md +13 -1
- data/lib/chronos/agent.rb +24 -0
- data/lib/chronos/application/apm_aggregator.rb +7 -10
- data/lib/chronos/application/apm_error_classifier.rb +27 -0
- data/lib/chronos/application/capture_telemetry.rb +1 -1
- data/lib/chronos/application/dependency_reporter.rb +129 -0
- data/lib/chronos/application/remote_configuration.rb +3 -1
- data/lib/chronos/configuration/apm_validation.rb +17 -1
- data/lib/chronos/configuration.rb +15 -2
- data/lib/chronos/core/cache_normalizer.rb +99 -0
- data/lib/chronos/core/telemetry_event.rb +1 -1
- data/lib/chronos/integrations/net_http.rb +165 -0
- data/lib/chronos/net_http.rb +4 -0
- data/lib/chronos/observability_facade.rb +43 -0
- data/lib/chronos/rails/notifications_subscriber.rb +6 -5
- data/lib/chronos/version.rb +1 -1
- data/lib/chronos.rb +6 -0
- metadata +12 -1
data/lib/chronos/agent.rb
CHANGED
|
@@ -37,13 +37,16 @@ module Chronos
|
|
|
37
37
|
pipeline_options
|
|
38
38
|
)
|
|
39
39
|
initialize_capture(options)
|
|
40
|
+
@dependency_reporter = options[:dependency_reporter] || Application::DependencyReporter.new(config)
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
def notify(exception, context = {})
|
|
44
|
+
report_dependencies
|
|
43
45
|
@capture.call(exception, context_for_capture(context))
|
|
44
46
|
end
|
|
45
47
|
|
|
46
48
|
def notify_sync(exception, context = {})
|
|
49
|
+
report_dependencies
|
|
47
50
|
@capture.call_sync(exception, context_for_capture(context))
|
|
48
51
|
end
|
|
49
52
|
|
|
@@ -64,9 +67,20 @@ module Chronos
|
|
|
64
67
|
end
|
|
65
68
|
|
|
66
69
|
def record_event(event_type, payload = {}, context = {})
|
|
70
|
+
report_dependencies unless event_type.to_s == "dependencies"
|
|
67
71
|
@telemetry.call(event_type, payload, telemetry_context(context))
|
|
68
72
|
end
|
|
69
73
|
|
|
74
|
+
def report_dependencies
|
|
75
|
+
payload = @dependency_reporter.call
|
|
76
|
+
return false unless payload
|
|
77
|
+
|
|
78
|
+
@telemetry.call("dependencies", payload, {})
|
|
79
|
+
rescue StandardError => error
|
|
80
|
+
@logger.warn("Chronos dependency reporting failed: #{error.class}")
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
|
|
70
84
|
def record_event_once(key, event_type, payload = {}, context = {})
|
|
71
85
|
execution = @context_store.get
|
|
72
86
|
captured = execution[:__chronos_captured_events] || {}
|
|
@@ -87,6 +101,14 @@ module Chronos
|
|
|
87
101
|
}
|
|
88
102
|
end
|
|
89
103
|
|
|
104
|
+
def external_http_integration_options
|
|
105
|
+
{:enabled => @config.external_http_enabled, :trace_headers => @config.external_http_trace_headers}
|
|
106
|
+
end
|
|
107
|
+
|
|
108
|
+
def cache_integration_options
|
|
109
|
+
{:project_id => @config.project_id, :key_mode => @config.cache_key_mode}
|
|
110
|
+
end
|
|
111
|
+
|
|
90
112
|
# Returns the correlation subset safe for an integration-owned process boundary.
|
|
91
113
|
def propagation_context
|
|
92
114
|
current = context_hash(@context_store.get)
|
|
@@ -124,6 +146,7 @@ module Chronos
|
|
|
124
146
|
end
|
|
125
147
|
|
|
126
148
|
def flush(timeout = DEFAULT_FLUSH_TIMEOUT)
|
|
149
|
+
report_dependencies
|
|
127
150
|
@telemetry.flush
|
|
128
151
|
@delivery_pipeline.flush(timeout)
|
|
129
152
|
rescue StandardError => error
|
|
@@ -132,6 +155,7 @@ module Chronos
|
|
|
132
155
|
end
|
|
133
156
|
|
|
134
157
|
def close(timeout = DEFAULT_FLUSH_TIMEOUT)
|
|
158
|
+
report_dependencies
|
|
135
159
|
@telemetry.flush
|
|
136
160
|
@delivery_pipeline.close(timeout)
|
|
137
161
|
rescue StandardError => error
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
module Chronos
|
|
2
2
|
module Application
|
|
3
|
-
# Aggregates bounded request, query, and
|
|
3
|
+
# Aggregates bounded request, query, job, and external HTTP observations into metric batches.
|
|
4
4
|
#
|
|
5
5
|
# @responsibility Maintain statistics, histograms, breakdown, and local query signals.
|
|
6
6
|
# @motivation Avoid one network event per observation while retaining diagnostic value.
|
|
@@ -14,7 +14,9 @@ module Chronos
|
|
|
14
14
|
# @errors Invalid observations are ignored and never escape to the application.
|
|
15
15
|
# @performance Group, transaction, query, bucket, and batch counts are strictly bounded.
|
|
16
16
|
class ApmAggregator
|
|
17
|
-
|
|
17
|
+
include ApmErrorClassifier
|
|
18
|
+
|
|
19
|
+
METRIC_TYPES = %w(request query job external_http).freeze
|
|
18
20
|
def initialize(config)
|
|
19
21
|
@config = config
|
|
20
22
|
@mutex = Mutex.new
|
|
@@ -77,7 +79,7 @@ module Chronos
|
|
|
77
79
|
@groups[key] = aggregate
|
|
78
80
|
end
|
|
79
81
|
duration = non_negative(payload["duration_ms"] || payload[:duration_ms])
|
|
80
|
-
status =
|
|
82
|
+
status = ["request", "external_http"].include?(type) ? payload["status"] || payload[:status] : nil
|
|
81
83
|
signals = signals_for(type, payload, context, duration)
|
|
82
84
|
breakdown = breakdown_for(type, payload, context, duration)
|
|
83
85
|
aggregate.observe(
|
|
@@ -177,6 +179,7 @@ module Chronos
|
|
|
177
179
|
when "request" then %w(route method)
|
|
178
180
|
when "query" then %w(adapter operation table fingerprint normalized_query name cached role shard source)
|
|
179
181
|
when "job" then %w(kind class queue status)
|
|
182
|
+
when "external_http" then %w(host method)
|
|
180
183
|
else []
|
|
181
184
|
end
|
|
182
185
|
names.each_with_object({}) do |name, result|
|
|
@@ -211,17 +214,11 @@ module Chronos
|
|
|
211
214
|
return "view" if type == "request" && payload["kind"].to_s == "view"
|
|
212
215
|
return "cache" if type == "cache"
|
|
213
216
|
return "queue" if type == "job"
|
|
217
|
+
return "external_http" if type == "external_http"
|
|
214
218
|
|
|
215
219
|
nil
|
|
216
220
|
end
|
|
217
221
|
|
|
218
|
-
def error?(type, payload)
|
|
219
|
-
return (payload["status"] || payload[:status]).to_i >= 500 if type == "request"
|
|
220
|
-
return (payload["status"] || payload[:status]).to_s == "failed" if type == "job"
|
|
221
|
-
|
|
222
|
-
!(payload["error_class"] || payload[:error_class]).to_s.empty?
|
|
223
|
-
end
|
|
224
|
-
|
|
225
222
|
def trace_id(context)
|
|
226
223
|
(context["trace_id"] || context[:trace_id]).to_s
|
|
227
224
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Application
|
|
3
|
+
# Classifies bounded APM observations without inspecting exception messages.
|
|
4
|
+
module ApmErrorClassifier
|
|
5
|
+
private
|
|
6
|
+
|
|
7
|
+
def error?(type, payload)
|
|
8
|
+
return status_error?(payload) if type == "request"
|
|
9
|
+
return (payload["status"] || payload[:status]).to_s == "failed" if type == "job"
|
|
10
|
+
return external_http_error?(payload) if type == "external_http"
|
|
11
|
+
|
|
12
|
+
!(payload["error_class"] || payload[:error_class]).to_s.empty?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def external_http_error?(payload)
|
|
16
|
+
return true unless (payload["error_class"] || payload[:error_class]).to_s.empty?
|
|
17
|
+
return true if payload["timeout"] == true || payload[:timeout] == true
|
|
18
|
+
|
|
19
|
+
status_error?(payload)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def status_error?(payload)
|
|
23
|
+
(payload["status"] || payload[:status]).to_i >= 500
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -4,7 +4,7 @@ module Chronos
|
|
|
4
4
|
#
|
|
5
5
|
# @responsibility Aggregate APM observations or serialize and enqueue integration events.
|
|
6
6
|
# @motivation Keep framework policy and local batching outside transport and domain objects.
|
|
7
|
-
# @limits It handles
|
|
7
|
+
# @limits It handles only the event types declared by TelemetryEvent.
|
|
8
8
|
# @collaborators ApmAggregator, TelemetryEvent, TelemetrySerializer, and DeliveryPipeline.
|
|
9
9
|
# @thread_safety Calls own event state and may execute concurrently.
|
|
10
10
|
# @compatibility Ruby 2.2.10 through Ruby 2.6; independent of Rails.
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Application
|
|
3
|
+
# Builds one bounded inventory from already loaded runtime components.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Report loaded gems and runtime/framework versions once per agent.
|
|
6
|
+
# @motivation Dependency context aids diagnosis without attaching the bundle to every error.
|
|
7
|
+
# @limits It does not read lockfiles, environment variables, gem paths, or open DB connections.
|
|
8
|
+
# @collaborators RubyGems loaded specs and immutable Chronos configuration.
|
|
9
|
+
# @thread_safety A mutex guarantees at-most-once collection across concurrent first events.
|
|
10
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6; all framework detection is optional.
|
|
11
|
+
# @example
|
|
12
|
+
# payload = DependencyReporter.new(config).call
|
|
13
|
+
# @errors Detection failures yield omitted fields and never escape.
|
|
14
|
+
# @performance Runs once and visits at most the configured number of loaded specs.
|
|
15
|
+
class DependencyReporter
|
|
16
|
+
def initialize(config, options = {})
|
|
17
|
+
@config = config
|
|
18
|
+
@loaded_specs = options[:loaded_specs] || proc { Gem.loaded_specs }
|
|
19
|
+
@constants = options[:constants] || {}
|
|
20
|
+
@mutex = Mutex.new
|
|
21
|
+
@reported = false
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def call
|
|
25
|
+
@mutex.synchronize do
|
|
26
|
+
return nil if @reported || !@config.dependency_reporting
|
|
27
|
+
|
|
28
|
+
@reported = true
|
|
29
|
+
build_payload
|
|
30
|
+
end
|
|
31
|
+
rescue StandardError
|
|
32
|
+
nil
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
private
|
|
36
|
+
|
|
37
|
+
def build_payload
|
|
38
|
+
payload = {
|
|
39
|
+
"dependencies" => dependencies,
|
|
40
|
+
"ruby" => {
|
|
41
|
+
"version" => bounded(RUBY_VERSION, 64), "engine" => bounded(ruby_engine, 64),
|
|
42
|
+
"platform" => bounded(RUBY_PLATFORM, 128)
|
|
43
|
+
},
|
|
44
|
+
"rails" => detected("rails") { rails_version },
|
|
45
|
+
"web_server" => detected("web_server") { web_server },
|
|
46
|
+
"database_adapter" => detected("database_adapter") { database_adapter },
|
|
47
|
+
"sidekiq" => detected("sidekiq") { sidekiq_version },
|
|
48
|
+
"release" => bounded(@config.app_version.to_s, 128)
|
|
49
|
+
}
|
|
50
|
+
payload.delete_if do |key, value|
|
|
51
|
+
!["dependencies", "ruby"].include?(key) && value.to_s.empty?
|
|
52
|
+
end
|
|
53
|
+
payload
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def dependencies
|
|
57
|
+
specs = @loaded_specs.call
|
|
58
|
+
values = specs.respond_to?(:values) ? specs.values : []
|
|
59
|
+
values.first(@config.dependency_max_items).sort_by { |spec| spec.name.to_s }.map do |spec|
|
|
60
|
+
{"name" => bounded(spec.name.to_s, 128), "version" => bounded(spec.version.to_s, 64)}
|
|
61
|
+
end
|
|
62
|
+
rescue StandardError
|
|
63
|
+
[]
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def detected(name)
|
|
67
|
+
explicit = @constants[name]
|
|
68
|
+
return bounded(explicit.to_s, 128) unless explicit.nil?
|
|
69
|
+
|
|
70
|
+
bounded(yield.to_s, 128)
|
|
71
|
+
rescue StandardError
|
|
72
|
+
""
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def rails_version
|
|
76
|
+
defined?(::Rails) && ::Rails.respond_to?(:version) ? ::Rails.version.to_s : loaded_version("rails")
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def sidekiq_version
|
|
80
|
+
defined?(::Sidekiq::VERSION) ? ::Sidekiq::VERSION.to_s : loaded_version("sidekiq")
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def web_server
|
|
84
|
+
return "Puma" if defined?(::Puma)
|
|
85
|
+
return "Unicorn" if defined?(::Unicorn)
|
|
86
|
+
return "Passenger" if defined?(::PhusionPassenger)
|
|
87
|
+
return "WEBrick" if defined?(::WEBrick)
|
|
88
|
+
|
|
89
|
+
""
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def database_adapter
|
|
93
|
+
names = loaded_spec_names
|
|
94
|
+
return "PostgreSQL" if names.include?("pg")
|
|
95
|
+
return "MySQL" if names.include?("mysql2")
|
|
96
|
+
return "SQLite" if names.include?("sqlite3")
|
|
97
|
+
|
|
98
|
+
""
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def loaded_version(name)
|
|
102
|
+
specs = @loaded_specs.call
|
|
103
|
+
spec = specs[name] || specs[name.to_sym] if specs.respond_to?(:[])
|
|
104
|
+
spec ? spec.version.to_s : ""
|
|
105
|
+
rescue StandardError
|
|
106
|
+
""
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def loaded_spec_names
|
|
110
|
+
specs = @loaded_specs.call
|
|
111
|
+
specs.respond_to?(:keys) ? specs.keys.map(&:to_s) : []
|
|
112
|
+
rescue StandardError
|
|
113
|
+
[]
|
|
114
|
+
end
|
|
115
|
+
|
|
116
|
+
def ruby_engine
|
|
117
|
+
defined?(RUBY_ENGINE) ? RUBY_ENGINE : "ruby"
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
def bounded(value, limit)
|
|
121
|
+
text = value.to_s
|
|
122
|
+
text = text.scrub("?") if text.respond_to?(:scrub)
|
|
123
|
+
text.bytesize > limit ? text.byteslice(0, limit) : text
|
|
124
|
+
rescue StandardError
|
|
125
|
+
""
|
|
126
|
+
end
|
|
127
|
+
end
|
|
128
|
+
end
|
|
129
|
+
end
|
|
@@ -13,7 +13,9 @@ module Chronos
|
|
|
13
13
|
# @errors Invalid documents are ignored and return false.
|
|
14
14
|
# @performance Validation is bounded by configured document and list limits.
|
|
15
15
|
class RemoteConfiguration
|
|
16
|
-
SUPPORTED_EVENT_TYPES =
|
|
16
|
+
SUPPORTED_EVENT_TYPES = %w(
|
|
17
|
+
exception request query job cache external_http dependencies metric_batch
|
|
18
|
+
).freeze
|
|
17
19
|
MAX_IGNORED_FINGERPRINTS = 100
|
|
18
20
|
MAX_FINGERPRINT_BYTES = 256
|
|
19
21
|
MIN_PAYLOAD_SIZE = 256
|
|
@@ -2,7 +2,7 @@ module Chronos
|
|
|
2
2
|
module Internal
|
|
3
3
|
# Validates bounded APM aggregation, histogram, and detector settings.
|
|
4
4
|
#
|
|
5
|
-
# @responsibility Return configuration errors for
|
|
5
|
+
# @responsibility Return configuration errors for bounded APM and observability options.
|
|
6
6
|
# @motivation Keep the general configuration validator focused and maintainable.
|
|
7
7
|
# @limits It validates shape and bounds but does not allocate aggregator state.
|
|
8
8
|
# @collaborators Chronos::Configuration predicates and attributes.
|
|
@@ -55,6 +55,22 @@ module Chronos
|
|
|
55
55
|
|
|
56
56
|
values.each_cons(2).all? { |left, right| right > left }
|
|
57
57
|
end
|
|
58
|
+
|
|
59
|
+
def observability_errors
|
|
60
|
+
errors = []
|
|
61
|
+
errors << "external_http_enabled must be true or false" unless boolean?(external_http_enabled)
|
|
62
|
+
errors << "external_http_trace_headers must be true or false" unless boolean?(external_http_trace_headers)
|
|
63
|
+
errors << "cache_key_mode must be :none or :sha256" unless [:none, :sha256].include?(cache_key_mode)
|
|
64
|
+
errors << "dependency_reporting must be true or false" unless boolean?(dependency_reporting)
|
|
65
|
+
unless dependency_max_items.is_a?(Integer) && dependency_max_items >= 1 && dependency_max_items <= 200
|
|
66
|
+
errors << "dependency_max_items must be between 1 and 200"
|
|
67
|
+
end
|
|
68
|
+
errors
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def boolean?(value)
|
|
72
|
+
[true, false].include?(value)
|
|
73
|
+
end
|
|
58
74
|
end
|
|
59
75
|
end
|
|
60
76
|
end
|
|
@@ -46,7 +46,8 @@ module Chronos
|
|
|
46
46
|
:apm_enabled, :apm_max_groups, :apm_flush_count, :apm_batch_size,
|
|
47
47
|
:apm_max_queries_per_request, :apm_slow_query_threshold_ms,
|
|
48
48
|
:apm_long_transaction_threshold_ms, :apm_n_plus_one_threshold,
|
|
49
|
-
:apm_histogram_buckets
|
|
49
|
+
:apm_histogram_buckets, :external_http_enabled, :external_http_trace_headers,
|
|
50
|
+
:cache_key_mode, :dependency_reporting, :dependency_max_items
|
|
50
51
|
].freeze
|
|
51
52
|
|
|
52
53
|
attr_accessor(*ATTRIBUTES)
|
|
@@ -57,6 +58,7 @@ module Chronos
|
|
|
57
58
|
initialize_resilience_defaults
|
|
58
59
|
initialize_rails_defaults
|
|
59
60
|
initialize_apm_defaults
|
|
61
|
+
initialize_observability_defaults
|
|
60
62
|
end
|
|
61
63
|
|
|
62
64
|
def snapshot
|
|
@@ -86,6 +88,7 @@ module Chronos
|
|
|
86
88
|
errors.concat(privacy_errors)
|
|
87
89
|
errors.concat(context_errors)
|
|
88
90
|
errors.concat(apm_errors)
|
|
91
|
+
errors.concat(observability_errors)
|
|
89
92
|
errors
|
|
90
93
|
end
|
|
91
94
|
|
|
@@ -136,6 +139,14 @@ module Chronos
|
|
|
136
139
|
@apm_histogram_buckets = [5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0]
|
|
137
140
|
end
|
|
138
141
|
|
|
142
|
+
def initialize_observability_defaults
|
|
143
|
+
@external_http_enabled = false
|
|
144
|
+
@external_http_trace_headers = true
|
|
145
|
+
@cache_key_mode = :none
|
|
146
|
+
@dependency_reporting = true
|
|
147
|
+
@dependency_max_items = 100
|
|
148
|
+
end
|
|
149
|
+
|
|
139
150
|
def initialize_privacy_defaults
|
|
140
151
|
@blocklist_keys = DEFAULT_BLOCKLIST_KEYS.dup
|
|
141
152
|
@allowlist_keys = []
|
|
@@ -155,7 +166,9 @@ module Chronos
|
|
|
155
166
|
@remote_configuration = true
|
|
156
167
|
@remote_config_max_bytes = 4096
|
|
157
168
|
@sampling_rate = 1.0
|
|
158
|
-
@enabled_event_types = [
|
|
169
|
+
@enabled_event_types = [
|
|
170
|
+
"exception", "request", "query", "job", "cache", "external_http", "dependencies", "metric_batch"
|
|
171
|
+
]
|
|
159
172
|
@max_remote_send_interval = 60.0
|
|
160
173
|
end
|
|
161
174
|
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
require "digest"
|
|
2
|
+
|
|
3
|
+
module Chronos
|
|
4
|
+
module Core
|
|
5
|
+
# Produces bounded cache metadata without exposing raw cache keys or values.
|
|
6
|
+
#
|
|
7
|
+
# @responsibility Normalize operation, backend, namespace, outcome, and optional key hash.
|
|
8
|
+
# @motivation Cache telemetry needs diagnostic identity without leaking application keys.
|
|
9
|
+
# @limits It never reads cache values and hashes keys only after explicit opt-in.
|
|
10
|
+
# @collaborators Rails notification subscriber and Chronos configuration.
|
|
11
|
+
# @thread_safety Instances hold only immutable scalar configuration.
|
|
12
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
13
|
+
# @example
|
|
14
|
+
# CacheNormalizer.new("project", :none).call(name, payload)
|
|
15
|
+
# @errors Unreadable fields become empty bounded strings.
|
|
16
|
+
# @performance Constant work plus SHA-256 only when key hashing is enabled.
|
|
17
|
+
class CacheNormalizer
|
|
18
|
+
def initialize(project_id, key_mode)
|
|
19
|
+
@project_id = project_id.to_s
|
|
20
|
+
@key_mode = key_mode
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def call(name, payload = {})
|
|
24
|
+
hit = hit_value(name, payload)
|
|
25
|
+
result = {
|
|
26
|
+
"operation" => bounded(name.to_s.split(".").first, 64),
|
|
27
|
+
"backend" => backend(payload),
|
|
28
|
+
"namespace" => bounded(namespace(payload).to_s, 128),
|
|
29
|
+
"hit" => hit,
|
|
30
|
+
"outcome" => outcome(hit)
|
|
31
|
+
}
|
|
32
|
+
result["key_hash"] = key_hash(value(payload, :key)) if @key_mode == :sha256
|
|
33
|
+
result.delete_if { |key, child| child.to_s.empty? && key != "hit" }
|
|
34
|
+
rescue StandardError
|
|
35
|
+
{"operation" => "cache", "outcome" => "unknown"}
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def outcome(hit)
|
|
41
|
+
return "unknown" if hit.nil?
|
|
42
|
+
|
|
43
|
+
hit ? "hit" : "miss"
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def backend(payload)
|
|
47
|
+
store = value(payload, :store)
|
|
48
|
+
return "" if store.nil?
|
|
49
|
+
|
|
50
|
+
name = store.is_a?(String) || store.is_a?(Symbol) ? store.to_s : store.class.name.to_s
|
|
51
|
+
bounded(name, 128)
|
|
52
|
+
rescue StandardError
|
|
53
|
+
""
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def namespace(payload)
|
|
57
|
+
direct = value(payload, :namespace)
|
|
58
|
+
return direct unless direct.nil?
|
|
59
|
+
|
|
60
|
+
value(value(payload, :options), :namespace)
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def hit_value(name, payload)
|
|
64
|
+
return true if name.to_s.start_with?("cache_fetch_hit")
|
|
65
|
+
|
|
66
|
+
hit = value(payload, :hit)
|
|
67
|
+
[true, false].include?(hit) ? hit : nil
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def key_hash(key)
|
|
71
|
+
return nil if key.nil?
|
|
72
|
+
|
|
73
|
+
Digest::SHA256.hexdigest("#{@project_id}:cache:#{safe_key(key)}")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def safe_key(key)
|
|
77
|
+
text = key.to_s
|
|
78
|
+
text = text.scrub("?") if text.respond_to?(:scrub)
|
|
79
|
+
text.bytesize > 2048 ? text.byteslice(0, 2048) : text
|
|
80
|
+
rescue StandardError
|
|
81
|
+
"[UNREADABLE]"
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def value(hash, key)
|
|
85
|
+
return nil unless hash.is_a?(Hash)
|
|
86
|
+
|
|
87
|
+
hash.key?(key) ? hash[key] : hash[key.to_s]
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def bounded(value, limit)
|
|
91
|
+
text = value.to_s
|
|
92
|
+
text = text.scrub("?") if text.respond_to?(:scrub)
|
|
93
|
+
text.bytesize > limit ? text.byteslice(0, limit) : text
|
|
94
|
+
rescue StandardError
|
|
95
|
+
""
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
99
|
+
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 metric_batch).freeze
|
|
19
|
+
TYPES = %w(request query job cache external_http dependencies metric_batch).freeze
|
|
20
20
|
|
|
21
21
|
attr_reader :event_id, :event_type, :timestamp, :context, :payload
|
|
22
22
|
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Integrations
|
|
3
|
+
# Optional per-instance Net::HTTP instrumentation without a global monkey patch.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Install one idempotent request wrapper on an explicit HTTP connection.
|
|
6
|
+
# @motivation Legacy Net::HTTP has no middleware callback but must remain safe and optional.
|
|
7
|
+
# @limits Class convenience methods and uninstrumented instances are deliberately untouched.
|
|
8
|
+
# @collaborators Net::HTTP-compatible connection, request objects, and Chronos notifier.
|
|
9
|
+
# @thread_safety Installation is synchronized per object; request state is call-local.
|
|
10
|
+
# @compatibility Net::HTTP on Ruby 2.2.10 through Ruby 2.6.
|
|
11
|
+
# @example
|
|
12
|
+
# Chronos::Integrations::NetHttp.install(Net::HTTP.new("api.example.com"))
|
|
13
|
+
# @errors Instrumentation errors are contained; original HTTP errors are re-raised unchanged.
|
|
14
|
+
# @performance Adds bounded hashes and clock reads; never reads request/response bodies.
|
|
15
|
+
module NetHttp
|
|
16
|
+
INSTALLED_KEY = :@__chronos_net_http_installed
|
|
17
|
+
OPTIONS_KEY = :@__chronos_net_http_options
|
|
18
|
+
|
|
19
|
+
class << self
|
|
20
|
+
def install(connection, options = {})
|
|
21
|
+
notifier = options[:notifier] || Chronos
|
|
22
|
+
return false unless enabled?(notifier)
|
|
23
|
+
return false unless connection.respond_to?(:request) && connection.respond_to?(:address)
|
|
24
|
+
|
|
25
|
+
mutex_for(connection).synchronize do
|
|
26
|
+
return false if connection.instance_variable_get(INSTALLED_KEY)
|
|
27
|
+
|
|
28
|
+
connection.instance_variable_set(OPTIONS_KEY, instrumentation_options(notifier, options))
|
|
29
|
+
connection.singleton_class.send(:prepend, InstrumentedRequest)
|
|
30
|
+
connection.instance_variable_set(INSTALLED_KEY, true)
|
|
31
|
+
end
|
|
32
|
+
true
|
|
33
|
+
rescue StandardError
|
|
34
|
+
false
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def enabled?(notifier)
|
|
40
|
+
return true unless notifier.respond_to?(:external_http_integration_options)
|
|
41
|
+
|
|
42
|
+
notifier.external_http_integration_options[:enabled] == true
|
|
43
|
+
rescue StandardError
|
|
44
|
+
false
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def instrumentation_options(notifier, options)
|
|
48
|
+
configured = if notifier.respond_to?(:external_http_integration_options)
|
|
49
|
+
notifier.external_http_integration_options
|
|
50
|
+
else
|
|
51
|
+
{}
|
|
52
|
+
end
|
|
53
|
+
{
|
|
54
|
+
:notifier => notifier,
|
|
55
|
+
:clock => options[:clock] || proc { monotonic_time },
|
|
56
|
+
:trace_headers => configured.fetch(:trace_headers, true)
|
|
57
|
+
}
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def mutex_for(connection)
|
|
61
|
+
key = :@__chronos_net_http_mutex
|
|
62
|
+
connection.instance_variable_get(key) || connection.instance_variable_set(key, Mutex.new)
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def monotonic_time
|
|
66
|
+
Process.clock_gettime(Process::CLOCK_MONOTONIC)
|
|
67
|
+
rescue StandardError
|
|
68
|
+
Time.now.to_f
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# Request wrapper prepended only to an explicitly selected HTTP object.
|
|
73
|
+
#
|
|
74
|
+
# @responsibility Inject bounded trace headers and record outcome/timing around `request`.
|
|
75
|
+
# @motivation Preserve the native API, streaming block, and exception semantics.
|
|
76
|
+
# @limits It never reads URL path/query, Authorization, request body, headers, or response body.
|
|
77
|
+
# @collaborators NetHttp installation options and Chronos record_event.
|
|
78
|
+
# @thread_safety All request observations use local variables.
|
|
79
|
+
# @compatibility Net::HTTP#request signature on Ruby 2.2.10 through Ruby 2.6.
|
|
80
|
+
# @example
|
|
81
|
+
# connection.request(Net::HTTP::Get.new("/health"))
|
|
82
|
+
# @errors Original StandardError values are recorded by class and re-raised unchanged.
|
|
83
|
+
# @performance Two clock reads and one asynchronous telemetry observation per call.
|
|
84
|
+
module InstrumentedRequest
|
|
85
|
+
def request(request, body = nil, &block)
|
|
86
|
+
options = instance_variable_get(NetHttp::OPTIONS_KEY)
|
|
87
|
+
started_at = options[:clock].call
|
|
88
|
+
inject_trace_headers(request, options) if options[:trace_headers]
|
|
89
|
+
response = super(request, body, &block)
|
|
90
|
+
record_external_http(options, request, started_at, response, nil)
|
|
91
|
+
response
|
|
92
|
+
rescue StandardError => error
|
|
93
|
+
record_external_http(options, request, started_at, nil, error) if options && started_at
|
|
94
|
+
raise
|
|
95
|
+
end
|
|
96
|
+
|
|
97
|
+
private
|
|
98
|
+
|
|
99
|
+
def inject_trace_headers(request, options)
|
|
100
|
+
return unless request.respond_to?(:[]) && request.respond_to?(:[]=)
|
|
101
|
+
|
|
102
|
+
context = if options[:notifier].respond_to?(:propagation_context)
|
|
103
|
+
options[:notifier].propagation_context
|
|
104
|
+
else
|
|
105
|
+
{}
|
|
106
|
+
end
|
|
107
|
+
set_header(request, "X-Chronos-Trace-ID", context["trace_id"] || context[:trace_id])
|
|
108
|
+
set_header(request, "X-Chronos-Request-ID", context["request_id"] || context[:request_id])
|
|
109
|
+
rescue StandardError
|
|
110
|
+
nil
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def set_header(request, name, value)
|
|
114
|
+
request[name] = value.to_s if request[name].to_s.empty? && !value.to_s.empty?
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def record_external_http(options, request, started_at, response, error)
|
|
118
|
+
payload = {
|
|
119
|
+
"host" => sanitized_external_host,
|
|
120
|
+
"method" => request.respond_to?(:method) ? request.method.to_s.upcase : "",
|
|
121
|
+
"status" => response_status(response),
|
|
122
|
+
"duration_ms" => ((options[:clock].call - started_at) * 1000.0).round(3),
|
|
123
|
+
"timeout" => timeout_error?(error),
|
|
124
|
+
"connection_error" => connection_error?(error),
|
|
125
|
+
"error_class" => error ? error.class.name.to_s : ""
|
|
126
|
+
}
|
|
127
|
+
payload.delete_if { |_key, value| value.nil? || value == "" }
|
|
128
|
+
options[:notifier].record_event("external_http", payload)
|
|
129
|
+
rescue StandardError
|
|
130
|
+
false
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def response_status(response)
|
|
134
|
+
response.code.to_i if response && response.respond_to?(:code)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def sanitized_external_host
|
|
138
|
+
host = respond_to?(:address) ? address.to_s.downcase.sub(/\.\z/, "") : ""
|
|
139
|
+
host = host.scrub("?") if host.respond_to?(:scrub)
|
|
140
|
+
host.bytesize > 253 ? host.byteslice(0, 253) : host
|
|
141
|
+
rescue StandardError
|
|
142
|
+
""
|
|
143
|
+
end
|
|
144
|
+
|
|
145
|
+
def timeout_error?(error)
|
|
146
|
+
return false unless error
|
|
147
|
+
|
|
148
|
+
error.is_a?(Timeout::Error) || !error.class.name.to_s.match(/Timeout/).nil?
|
|
149
|
+
rescue StandardError
|
|
150
|
+
false
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
def connection_error?(error)
|
|
154
|
+
return false unless error
|
|
155
|
+
return false if timeout_error?(error)
|
|
156
|
+
|
|
157
|
+
error.is_a?(SystemCallError) || error.is_a?(IOError) ||
|
|
158
|
+
!error.class.name.to_s.match(/SocketError|OpenSSL::SSL::SSLError/).nil?
|
|
159
|
+
rescue StandardError
|
|
160
|
+
false
|
|
161
|
+
end
|
|
162
|
+
end
|
|
163
|
+
end
|
|
164
|
+
end
|
|
165
|
+
end
|