chronos-ruby 0.6.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 +48 -0
- data/README.md +53 -10
- data/contracts/apm-batch-v1.schema.json +35 -0
- data/contracts/dependencies-v1.schema.json +38 -0
- data/contracts/event-v1.schema.json +1 -1
- data/docs/adr/ADR-015-bounded-apm-aggregation.md +27 -0
- data/docs/adr/ADR-016-explicit-observability-integrations.md +25 -0
- data/docs/architecture.md +11 -2
- data/docs/compatibility.md +4 -0
- data/docs/configuration.md +28 -1
- data/docs/data-collected.md +17 -3
- data/docs/examples/plain-ruby.md +7 -1
- data/docs/modules/apm-aggregation.md +57 -0
- data/docs/modules/cache-observability.md +16 -0
- data/docs/modules/configuration.md +4 -0
- data/docs/modules/dependencies.md +18 -0
- data/docs/modules/external-http.md +27 -0
- data/docs/modules/rails-legacy.md +2 -2
- data/docs/modules/sidekiq-legacy.md +2 -2
- data/docs/modules/telemetry-events.md +2 -2
- data/docs/performance.md +29 -1
- data/docs/privacy-lgpd.md +16 -2
- data/docs/troubleshooting.md +25 -1
- data/lib/chronos/agent.rb +49 -0
- data/lib/chronos/application/apm_aggregator.rb +270 -0
- data/lib/chronos/application/apm_error_classifier.rb +27 -0
- data/lib/chronos/application/capture_telemetry.rb +45 -5
- data/lib/chronos/application/delivery_pipeline.rb +7 -0
- data/lib/chronos/application/dependency_reporter.rb +129 -0
- data/lib/chronos/application/remote_configuration.rb +8 -1
- data/lib/chronos/configuration/apm_validation.rb +76 -0
- data/lib/chronos/configuration.rb +35 -2
- data/lib/chronos/core/cache_normalizer.rb +99 -0
- 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/net_http.rb +165 -0
- data/lib/chronos/integrations/rack/middleware.rb +19 -1
- data/lib/chronos/integrations/sidekiq.rb +2 -0
- data/lib/chronos/net_http.rb +4 -0
- data/lib/chronos/observability_facade.rb +43 -0
- data/lib/chronos/rails/notifications_subscriber.rb +42 -9
- data/lib/chronos/version.rb +1 -1
- data/lib/chronos.rb +23 -0
- metadata +19 -1
|
@@ -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
|
|
@@ -63,6 +65,11 @@ module Chronos
|
|
|
63
65
|
snapshot["enabled_event_types"].include?(event_type.to_s)
|
|
64
66
|
end
|
|
65
67
|
|
|
68
|
+
def delivery_enabled?(event_type)
|
|
69
|
+
values = snapshot
|
|
70
|
+
!values["kill_switch"] && values["enabled_event_types"].include?(event_type.to_s)
|
|
71
|
+
end
|
|
72
|
+
|
|
66
73
|
def ignored_fingerprint?(fingerprint)
|
|
67
74
|
snapshot["ignored_fingerprints"].include?(fingerprint.to_s)
|
|
68
75
|
end
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
module Chronos
|
|
2
|
+
module Internal
|
|
3
|
+
# Validates bounded APM aggregation, histogram, and detector settings.
|
|
4
|
+
#
|
|
5
|
+
# @responsibility Return configuration errors for bounded APM and observability options.
|
|
6
|
+
# @motivation Keep the general configuration validator focused and maintainable.
|
|
7
|
+
# @limits It validates shape and bounds but does not allocate aggregator state.
|
|
8
|
+
# @collaborators Chronos::Configuration predicates and attributes.
|
|
9
|
+
# @thread_safety Reads one mutable configuration instance without shared state.
|
|
10
|
+
# @compatibility Ruby 2.2.10 through Ruby 2.6.
|
|
11
|
+
# @errors Invalid values become messages and never raise from validation.
|
|
12
|
+
module ApmConfigurationValidation
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def apm_errors
|
|
16
|
+
errors = apm_capacity_errors
|
|
17
|
+
errors.concat(apm_threshold_errors)
|
|
18
|
+
errors << "apm_enabled must be true or false" unless [true, false].include?(apm_enabled)
|
|
19
|
+
unless increasing_positive_numbers?(apm_histogram_buckets)
|
|
20
|
+
errors << "apm_histogram_buckets must contain increasing positive numbers"
|
|
21
|
+
end
|
|
22
|
+
errors
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def apm_capacity_errors
|
|
26
|
+
errors = []
|
|
27
|
+
errors << "apm_max_groups must be a positive integer" unless positive_integer?(apm_max_groups)
|
|
28
|
+
errors << "apm_flush_count must be a positive integer" unless positive_integer?(apm_flush_count)
|
|
29
|
+
unless apm_batch_size.is_a?(Integer) && apm_batch_size >= 1 && apm_batch_size <= 50
|
|
30
|
+
errors << "apm_batch_size must be between 1 and 50"
|
|
31
|
+
end
|
|
32
|
+
unless positive_integer?(apm_max_queries_per_request)
|
|
33
|
+
errors << "apm_max_queries_per_request must be a positive integer"
|
|
34
|
+
end
|
|
35
|
+
errors
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def apm_threshold_errors
|
|
39
|
+
errors = []
|
|
40
|
+
unless positive_number?(apm_slow_query_threshold_ms)
|
|
41
|
+
errors << "apm_slow_query_threshold_ms must be greater than zero"
|
|
42
|
+
end
|
|
43
|
+
unless positive_number?(apm_long_transaction_threshold_ms)
|
|
44
|
+
errors << "apm_long_transaction_threshold_ms must be greater than zero"
|
|
45
|
+
end
|
|
46
|
+
unless apm_n_plus_one_threshold.is_a?(Integer) && apm_n_plus_one_threshold >= 2
|
|
47
|
+
errors << "apm_n_plus_one_threshold must be an integer greater than or equal to 2"
|
|
48
|
+
end
|
|
49
|
+
errors
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def increasing_positive_numbers?(values)
|
|
53
|
+
return false unless values.is_a?(Array) && !values.empty? && values.length <= 19
|
|
54
|
+
return false unless values.all? { |value| positive_number?(value) }
|
|
55
|
+
|
|
56
|
+
values.each_cons(2).all? { |left, right| right > left }
|
|
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
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "uri"
|
|
2
2
|
require "chronos/configuration/validation"
|
|
3
|
+
require "chronos/configuration/apm_validation"
|
|
3
4
|
|
|
4
5
|
module Chronos
|
|
5
6
|
# Mutable configuration used only while the Chronos agent is being set up.
|
|
@@ -21,6 +22,7 @@ module Chronos
|
|
|
21
22
|
# snapshot = config.snapshot
|
|
22
23
|
class Configuration
|
|
23
24
|
include Internal::ConfigurationValidation
|
|
25
|
+
include Internal::ApmConfigurationValidation
|
|
24
26
|
DEFAULT_BLOCKLIST_KEYS = %w(
|
|
25
27
|
password password_confirmation passwd secret api_key apikey authorization
|
|
26
28
|
token access_token refresh_token private_key client_secret cookie set-cookie
|
|
@@ -40,7 +42,12 @@ module Chronos
|
|
|
40
42
|
:remote_configuration, :remote_config_max_bytes, :sampling_rate,
|
|
41
43
|
:enabled_event_types, :max_remote_send_interval, :context_store,
|
|
42
44
|
:breadcrumb_capacity, :breadcrumb_max_bytes, :rails_enabled,
|
|
43
|
-
:rails_capture_in_console, :rails_capture_in_test, :rails_capture_user_agent
|
|
45
|
+
:rails_capture_in_console, :rails_capture_in_test, :rails_capture_user_agent,
|
|
46
|
+
:apm_enabled, :apm_max_groups, :apm_flush_count, :apm_batch_size,
|
|
47
|
+
:apm_max_queries_per_request, :apm_slow_query_threshold_ms,
|
|
48
|
+
:apm_long_transaction_threshold_ms, :apm_n_plus_one_threshold,
|
|
49
|
+
:apm_histogram_buckets, :external_http_enabled, :external_http_trace_headers,
|
|
50
|
+
:cache_key_mode, :dependency_reporting, :dependency_max_items
|
|
44
51
|
].freeze
|
|
45
52
|
|
|
46
53
|
attr_accessor(*ATTRIBUTES)
|
|
@@ -50,6 +57,8 @@ module Chronos
|
|
|
50
57
|
initialize_privacy_defaults
|
|
51
58
|
initialize_resilience_defaults
|
|
52
59
|
initialize_rails_defaults
|
|
60
|
+
initialize_apm_defaults
|
|
61
|
+
initialize_observability_defaults
|
|
53
62
|
end
|
|
54
63
|
|
|
55
64
|
def snapshot
|
|
@@ -78,6 +87,8 @@ module Chronos
|
|
|
78
87
|
errors.concat(resilience_errors)
|
|
79
88
|
errors.concat(privacy_errors)
|
|
80
89
|
errors.concat(context_errors)
|
|
90
|
+
errors.concat(apm_errors)
|
|
91
|
+
errors.concat(observability_errors)
|
|
81
92
|
errors
|
|
82
93
|
end
|
|
83
94
|
|
|
@@ -116,6 +127,26 @@ module Chronos
|
|
|
116
127
|
@rails_capture_user_agent = false
|
|
117
128
|
end
|
|
118
129
|
|
|
130
|
+
def initialize_apm_defaults
|
|
131
|
+
@apm_enabled = true
|
|
132
|
+
@apm_max_groups = 200
|
|
133
|
+
@apm_flush_count = 100
|
|
134
|
+
@apm_batch_size = 50
|
|
135
|
+
@apm_max_queries_per_request = 100
|
|
136
|
+
@apm_slow_query_threshold_ms = 500.0
|
|
137
|
+
@apm_long_transaction_threshold_ms = 1000.0
|
|
138
|
+
@apm_n_plus_one_threshold = 5
|
|
139
|
+
@apm_histogram_buckets = [5.0, 10.0, 25.0, 50.0, 100.0, 250.0, 500.0, 1000.0, 2500.0, 5000.0]
|
|
140
|
+
end
|
|
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
|
+
|
|
119
150
|
def initialize_privacy_defaults
|
|
120
151
|
@blocklist_keys = DEFAULT_BLOCKLIST_KEYS.dup
|
|
121
152
|
@allowlist_keys = []
|
|
@@ -135,7 +166,9 @@ module Chronos
|
|
|
135
166
|
@remote_configuration = true
|
|
136
167
|
@remote_config_max_bytes = 4096
|
|
137
168
|
@sampling_rate = 1.0
|
|
138
|
-
@enabled_event_types = [
|
|
169
|
+
@enabled_event_types = [
|
|
170
|
+
"exception", "request", "query", "job", "cache", "external_http", "dependencies", "metric_batch"
|
|
171
|
+
]
|
|
139
172
|
@max_remote_send_interval = 60.0
|
|
140
173
|
end
|
|
141
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
|
|
@@ -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 external_http dependencies metric_batch).freeze
|
|
20
20
|
|
|
21
21
|
attr_reader :event_id, :event_type, :timestamp, :context, :payload
|
|
22
22
|
|