chronos-ruby 0.6.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.
@@ -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
 
@@ -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
@@ -176,6 +176,8 @@ module Chronos
176
176
  propagated = {} unless propagated.is_a?(Hash)
177
177
  {
178
178
  :context => propagated.merge("job" => job_context(payload)),
179
+ :parameters => {"arguments" => payload["arguments"]},
180
+ :tags => payload["tags"],
179
181
  :__chronos_captured_exceptions => {}
180
182
  }
181
183
  end
@@ -29,6 +29,7 @@ module Chronos
29
29
  def initialize(notifier = Chronos, notifications = nil)
30
30
  @notifier = notifier
31
31
  @notifications = notifications || active_support_notifications
32
+ @sql_normalizer = Core::SqlNormalizer.new
32
33
  end
33
34
 
34
35
  def install
@@ -105,7 +106,7 @@ module Chronos
105
106
  "route" => route(payload),
106
107
  "duration_ms" => duration, "parameters" => hash(value(payload, :params))
107
108
  }
108
- @notifier.record_event("request", data)
109
+ record_once("request", "request", data)
109
110
  end
110
111
 
111
112
  def render_template(payload, duration)
@@ -117,10 +118,15 @@ module Chronos
117
118
  end
118
119
 
119
120
  def sql(payload, duration)
120
- data = {
121
- "name" => value(payload, :name).to_s, "cached" => value(payload, :cached) == true,
122
- "duration_ms" => duration
121
+ metadata = {
122
+ :name => value(payload, :name), :cached => value(payload, :cached),
123
+ :adapter => value(payload, :adapter), :connection => value(payload, :connection),
124
+ :role => value(payload, :connection_role) || value(payload, :role),
125
+ :shard => value(payload, :connection_shard) || value(payload, :shard),
126
+ :exception_object => value(payload, :exception_object), :exception => value(payload, :exception)
123
127
  }
128
+ metadata[:source] = sampled_query_source if duration >= slow_query_threshold
129
+ data = @sql_normalizer.call(value(payload, :sql), metadata).merge("duration_ms" => duration)
124
130
  @notifier.record_event("query", data)
125
131
  end
126
132
 
@@ -198,6 +204,32 @@ module Chronos
198
204
  rescue StandardError
199
205
  ""
200
206
  end
207
+
208
+ def record_once(key, event_type, payload)
209
+ if @notifier.respond_to?(:record_event_once)
210
+ @notifier.record_event_once(key, event_type, payload)
211
+ else
212
+ @notifier.record_event(event_type, payload)
213
+ end
214
+ end
215
+
216
+ def slow_query_threshold
217
+ options = @notifier.respond_to?(:apm_integration_options) ? @notifier.apm_integration_options : {}
218
+ (options[:slow_query_threshold_ms] || 500.0).to_f
219
+ rescue StandardError
220
+ 500.0
221
+ end
222
+
223
+ def sampled_query_source
224
+ options = @notifier.respond_to?(:apm_integration_options) ? @notifier.apm_integration_options : {}
225
+ root = options[:root_directory].to_s
226
+ frame = caller.find do |line|
227
+ (root.empty? || line.start_with?(root)) && line !~ %r{/lib/chronos/}
228
+ end
229
+ frame.to_s.sub(/:in .*/, "")[0, 256]
230
+ rescue StandardError
231
+ ""
232
+ end
201
233
  end
202
234
  end
203
235
  end
@@ -1,4 +1,4 @@
1
1
  module Chronos
2
2
  # Current version of the legacy Chronos Ruby agent.
3
- VERSION = "0.6.0.pre.1".freeze
3
+ VERSION = "0.7.0.pre.1".freeze
4
4
  end
data/lib/chronos.rb CHANGED
@@ -16,6 +16,8 @@ require "chronos/core/sanitizer"
16
16
  require "chronos/core/safe_serializer"
17
17
  require "chronos/core/payload_serializer"
18
18
  require "chronos/core/telemetry_event"
19
+ require "chronos/core/sql_normalizer"
20
+ require "chronos/core/metric_aggregate"
19
21
  require "chronos/ports/transport"
20
22
  require "chronos/ports/context_store"
21
23
  require "chronos/internal/safe_logger"
@@ -30,6 +32,7 @@ require "chronos/application/circuit_breaker"
30
32
  require "chronos/application/remote_configuration"
31
33
  require "chronos/application/delivery_pipeline"
32
34
  require "chronos/application/capture_exception"
35
+ require "chronos/application/apm_aggregator"
33
36
  require "chronos/application/capture_telemetry"
34
37
  require "chronos/agent"
35
38
  require "chronos/integrations"
@@ -104,6 +107,20 @@ module Chronos
104
107
  false
105
108
  end
106
109
 
110
+ def record_event_once(key, event_type, payload = {}, context = {})
111
+ agent = current_agent
112
+ agent ? agent.record_event_once(key, event_type, payload, context) : false
113
+ rescue StandardError
114
+ false
115
+ end
116
+
117
+ def apm_integration_options
118
+ agent = current_agent
119
+ agent ? agent.apm_integration_options : {:enabled => false}
120
+ rescue StandardError
121
+ {:enabled => false}
122
+ end
123
+
107
124
  # Returns only trace/request identifiers for optional process-boundary adapters.
108
125
  def propagation_context
109
126
  agent = current_agent
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: chronos-ruby
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0.pre.1
4
+ version: 0.7.0.pre.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Antonio Jefferson
@@ -86,6 +86,7 @@ files:
86
86
  - LICENSE.txt
87
87
  - README.md
88
88
  - SECURITY.md
89
+ - contracts/apm-batch-v1.schema.json
89
90
  - contracts/event-v1.schema.json
90
91
  - contracts/rack-context-v1.schema.json
91
92
  - contracts/sidekiq-job-v1.schema.json
@@ -99,11 +100,13 @@ files:
99
100
  - docs/adr/ADR-012-rack-context-isolation.md
100
101
  - docs/adr/ADR-013-legacy-rails-notifications.md
101
102
  - docs/adr/ADR-014-sidekiq-envelope-context.md
103
+ - docs/adr/ADR-015-bounded-apm-aggregation.md
102
104
  - docs/architecture.md
103
105
  - docs/compatibility.md
104
106
  - docs/configuration.md
105
107
  - docs/data-collected.md
106
108
  - docs/examples/plain-ruby.md
109
+ - docs/modules/apm-aggregation.md
107
110
  - docs/modules/async-queue.md
108
111
  - docs/modules/configuration.md
109
112
  - docs/modules/notice-pipeline.md
@@ -125,6 +128,7 @@ files:
125
128
  - lib/chronos/adapters/thread_local_context_store.rb
126
129
  - lib/chronos/agent.rb
127
130
  - lib/chronos/application.rb
131
+ - lib/chronos/application/apm_aggregator.rb
128
132
  - lib/chronos/application/capture_exception.rb
129
133
  - lib/chronos/application/capture_telemetry.rb
130
134
  - lib/chronos/application/circuit_breaker.rb
@@ -132,12 +136,14 @@ files:
132
136
  - lib/chronos/application/remote_configuration.rb
133
137
  - lib/chronos/application/retry_policy.rb
134
138
  - lib/chronos/configuration.rb
139
+ - lib/chronos/configuration/apm_validation.rb
135
140
  - lib/chronos/configuration/snapshot.rb
136
141
  - lib/chronos/configuration/validation.rb
137
142
  - lib/chronos/core.rb
138
143
  - lib/chronos/core/backtrace_parser.rb
139
144
  - lib/chronos/core/breadcrumb.rb
140
145
  - lib/chronos/core/exception_cause_collector.rb
146
+ - lib/chronos/core/metric_aggregate.rb
141
147
  - lib/chronos/core/notice.rb
142
148
  - lib/chronos/core/notice_builder.rb
143
149
  - lib/chronos/core/payload_serializer.rb
@@ -145,6 +151,7 @@ files:
145
151
  - lib/chronos/core/safe_serializer.rb
146
152
  - lib/chronos/core/sanitizer.rb
147
153
  - lib/chronos/core/sensitive_value_filter.rb
154
+ - lib/chronos/core/sql_normalizer.rb
148
155
  - lib/chronos/core/telemetry_event.rb
149
156
  - lib/chronos/errors.rb
150
157
  - lib/chronos/integrations.rb