opentrace 0.8.0 → 0.12.0
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/lib/opentrace/breadcrumbs.rb +46 -0
- data/lib/opentrace/client.rb +93 -4
- data/lib/opentrace/config.rb +86 -8
- data/lib/opentrace/local_vars.rb +45 -0
- data/lib/opentrace/middleware.rb +36 -0
- data/lib/opentrace/payload_builder.rb +227 -0
- data/lib/opentrace/pii_scrubber.rb +64 -0
- data/lib/opentrace/rails.rb +127 -113
- data/lib/opentrace/request_collector.rb +26 -1
- data/lib/opentrace/runtime_monitor.rb +75 -0
- data/lib/opentrace/sampler.rb +41 -0
- data/lib/opentrace/source_context.rb +87 -0
- data/lib/opentrace/sql_normalizer.rb +44 -0
- data/lib/opentrace/stats.rb +10 -4
- data/lib/opentrace/trace_formatter.rb +47 -0
- data/lib/opentrace/version.rb +1 -1
- data/lib/opentrace.rb +322 -72
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: f91e8fcf70aeeb601265b588138c85d00c5a96d034278caa4226072eb75c440d
|
|
4
|
+
data.tar.gz: ed9d094bf4af6d4545f70d464753bfd594e4d0ddf5758a977e6205bc2d6ad4ef
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 56a8cea4c49c71aa18b467e041ceba338646883cc9b158d9b28efea5d741148b08e911c762f7a596b9ec7edfd93804875a14f0038e5bee08333c81afeb52865b
|
|
7
|
+
data.tar.gz: 7f66b1c9f6b55a37e1bfe2adbe2731f4e87b32eeab068287782b8abbd734a2f9930dccfb68f2fe2e84f2e67ac85798b91abe44262b7f46c3d83a3d3e439cdf97
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenTrace
|
|
4
|
+
class Breadcrumb
|
|
5
|
+
attr_reader :category, :message, :data, :timestamp, :level
|
|
6
|
+
|
|
7
|
+
def initialize(category:, message:, data: nil, level: "info")
|
|
8
|
+
@category = category.to_s
|
|
9
|
+
@message = message.to_s
|
|
10
|
+
@data = data
|
|
11
|
+
@level = level.to_s
|
|
12
|
+
@timestamp = Process.clock_gettime(Process::CLOCK_REALTIME)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def to_h
|
|
16
|
+
h = { category: @category, message: @message, level: @level, timestamp: @timestamp }
|
|
17
|
+
h[:data] = @data if @data
|
|
18
|
+
h
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
class BreadcrumbBuffer
|
|
23
|
+
MAX_BREADCRUMBS = 25
|
|
24
|
+
|
|
25
|
+
def initialize
|
|
26
|
+
@buffer = []
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def add(breadcrumb)
|
|
30
|
+
@buffer.shift if @buffer.size >= MAX_BREADCRUMBS
|
|
31
|
+
@buffer << breadcrumb
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def to_a
|
|
35
|
+
@buffer.map(&:to_h)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def empty?
|
|
39
|
+
@buffer.empty?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def size
|
|
43
|
+
@buffer.size
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
data/lib/opentrace/client.rb
CHANGED
|
@@ -16,8 +16,9 @@ module OpenTrace
|
|
|
16
16
|
|
|
17
17
|
attr_reader :stats
|
|
18
18
|
|
|
19
|
-
def initialize(config)
|
|
19
|
+
def initialize(config, sampler: nil)
|
|
20
20
|
@config = config
|
|
21
|
+
@sampler = sampler
|
|
21
22
|
@queue = Thread::Queue.new
|
|
22
23
|
@mutex = Mutex.new
|
|
23
24
|
@thread = nil
|
|
@@ -140,6 +141,16 @@ module OpenTrace
|
|
|
140
141
|
next if batch.empty?
|
|
141
142
|
|
|
142
143
|
send_batch(batch)
|
|
144
|
+
|
|
145
|
+
# Adjust backpressure based on queue depth
|
|
146
|
+
if @sampler
|
|
147
|
+
queue_pct = @queue.size.to_f / MAX_QUEUE_SIZE
|
|
148
|
+
if queue_pct > 0.75
|
|
149
|
+
@sampler.increase_backpressure!
|
|
150
|
+
elsif queue_pct < 0.25
|
|
151
|
+
@sampler.decrease_backpressure!
|
|
152
|
+
end
|
|
153
|
+
end
|
|
143
154
|
end
|
|
144
155
|
rescue Exception # rubocop:disable Lint/RescueException
|
|
145
156
|
# Swallow all errors including thread kill
|
|
@@ -212,8 +223,25 @@ module OpenTrace
|
|
|
212
223
|
# Disable HTTP tracking for our own calls to prevent infinite recursion
|
|
213
224
|
Fiber[:opentrace_http_tracking_disabled] = true
|
|
214
225
|
|
|
215
|
-
#
|
|
216
|
-
batch = batch.
|
|
226
|
+
# Materialize deferred entries + apply before_send filter + truncate
|
|
227
|
+
batch = batch.filter_map do |item|
|
|
228
|
+
payload = PayloadBuilder.materialize(item, @config)
|
|
229
|
+
next nil unless payload
|
|
230
|
+
if @config.before_send
|
|
231
|
+
payload = @config.before_send.call(payload) rescue payload
|
|
232
|
+
unless payload
|
|
233
|
+
@stats.increment(:dropped_filtered)
|
|
234
|
+
next nil
|
|
235
|
+
end
|
|
236
|
+
end
|
|
237
|
+
# PII scrubbing (runs on background thread)
|
|
238
|
+
if @config.pii_scrubbing && payload[:metadata]
|
|
239
|
+
active_patterns = build_pii_patterns
|
|
240
|
+
PiiScrubber.scrub!(payload[:metadata], patterns: active_patterns)
|
|
241
|
+
end
|
|
242
|
+
|
|
243
|
+
fit_payload(payload)
|
|
244
|
+
end
|
|
217
245
|
return if batch.empty?
|
|
218
246
|
|
|
219
247
|
json = JSON.generate(batch)
|
|
@@ -227,7 +255,11 @@ module OpenTrace
|
|
|
227
255
|
return
|
|
228
256
|
end
|
|
229
257
|
|
|
230
|
-
response =
|
|
258
|
+
response = if @config.transport == :unix_socket
|
|
259
|
+
unix_socket_send(json)
|
|
260
|
+
else
|
|
261
|
+
send_with_retry(json)
|
|
262
|
+
end
|
|
231
263
|
handle_response(response, batch, json.bytesize)
|
|
232
264
|
rescue StandardError
|
|
233
265
|
@circuit_breaker.record_failure
|
|
@@ -251,6 +283,7 @@ module OpenTrace
|
|
|
251
283
|
@stats.increment(:delivered, batch.size)
|
|
252
284
|
@stats.increment(:batches_sent)
|
|
253
285
|
@stats.increment(:bytes_sent, bytes)
|
|
286
|
+
@config.after_send&.call(batch.size, bytes) rescue nil
|
|
254
287
|
when Net::HTTPTooManyRequests
|
|
255
288
|
handle_rate_limit(response, batch)
|
|
256
289
|
when Net::HTTPUnauthorized
|
|
@@ -351,6 +384,32 @@ module OpenTrace
|
|
|
351
384
|
persistent_http.request(request)
|
|
352
385
|
end
|
|
353
386
|
|
|
387
|
+
def unix_socket_send(json)
|
|
388
|
+
payload = if @config.compression && json.bytesize > @config.compression_threshold
|
|
389
|
+
gzip_compress(json)
|
|
390
|
+
else
|
|
391
|
+
json
|
|
392
|
+
end
|
|
393
|
+
|
|
394
|
+
socket = UNIXSocket.new(@config.socket_path)
|
|
395
|
+
# Protocol: 4-byte big-endian length prefix + payload
|
|
396
|
+
socket.write([payload.bytesize].pack("N"))
|
|
397
|
+
socket.write(payload)
|
|
398
|
+
socket.flush
|
|
399
|
+
|
|
400
|
+
# Read 4-byte status code response
|
|
401
|
+
response_data = socket.read(4)
|
|
402
|
+
status = response_data&.unpack1("N") || 500
|
|
403
|
+
socket.close
|
|
404
|
+
|
|
405
|
+
UnixSocketResponse.new(status)
|
|
406
|
+
rescue Errno::ECONNREFUSED, Errno::ENOENT, Errno::ENOTSOCK
|
|
407
|
+
# Socket not available — fall back to HTTP
|
|
408
|
+
send_with_retry(json)
|
|
409
|
+
rescue StandardError
|
|
410
|
+
nil
|
|
411
|
+
end
|
|
412
|
+
|
|
354
413
|
def retryable_response?(response)
|
|
355
414
|
response.code.to_i >= 500
|
|
356
415
|
end
|
|
@@ -394,6 +453,22 @@ module OpenTrace
|
|
|
394
453
|
io.string
|
|
395
454
|
end
|
|
396
455
|
|
|
456
|
+
def build_pii_patterns
|
|
457
|
+
patterns = PiiScrubber::PATTERNS.dup
|
|
458
|
+
# Remove disabled patterns
|
|
459
|
+
if @config.pii_disabled_patterns
|
|
460
|
+
@config.pii_disabled_patterns.each { |name| patterns.delete(name) }
|
|
461
|
+
end
|
|
462
|
+
result = patterns.values
|
|
463
|
+
# Add custom patterns
|
|
464
|
+
if @config.pii_patterns
|
|
465
|
+
result.concat(@config.pii_patterns)
|
|
466
|
+
end
|
|
467
|
+
result
|
|
468
|
+
rescue StandardError
|
|
469
|
+
PiiScrubber::PATTERNS.values
|
|
470
|
+
end
|
|
471
|
+
|
|
397
472
|
def fire_on_drop(count, reason)
|
|
398
473
|
@config.on_drop&.call(count, reason)
|
|
399
474
|
rescue StandardError
|
|
@@ -458,6 +533,20 @@ module OpenTrace
|
|
|
458
533
|
http.request(request)
|
|
459
534
|
end
|
|
460
535
|
|
|
536
|
+
# Adapts a numeric status code from Unix socket into Net::HTTP response duck type
|
|
537
|
+
UnixSocketResponse = Struct.new(:code) do
|
|
538
|
+
def is_a?(klass)
|
|
539
|
+
c = code.to_i
|
|
540
|
+
case klass.name
|
|
541
|
+
when "Net::HTTPSuccess" then c >= 200 && c < 300
|
|
542
|
+
when "Net::HTTPTooManyRequests" then c == 429
|
|
543
|
+
when "Net::HTTPUnauthorized" then c == 401
|
|
544
|
+
when "Net::HTTPServerError" then c >= 500 && c < 600
|
|
545
|
+
else super
|
|
546
|
+
end
|
|
547
|
+
end
|
|
548
|
+
end
|
|
549
|
+
|
|
461
550
|
def truncate_payload(payload)
|
|
462
551
|
meta = payload[:metadata]&.dup || {}
|
|
463
552
|
|
data/lib/opentrace/config.rb
CHANGED
|
@@ -4,9 +4,10 @@ module OpenTrace
|
|
|
4
4
|
class Config
|
|
5
5
|
REQUIRED_FIELDS = %i[endpoint api_key service].freeze
|
|
6
6
|
LEVELS = { debug: 0, info: 1, warn: 2, error: 3, fatal: 4 }.freeze
|
|
7
|
+
LEVEL_INTS = { "DEBUG" => 0, "INFO" => 1, "WARN" => 2, "ERROR" => 3, "FATAL" => 4 }.freeze
|
|
7
8
|
|
|
8
9
|
attr_accessor :endpoint, :api_key, :service, :environment, :timeout, :enabled,
|
|
9
|
-
:context, :
|
|
10
|
+
:context, :hostname, :pid, :git_sha,
|
|
10
11
|
:batch_size, :flush_interval,
|
|
11
12
|
:max_retries, :retry_base_delay, :retry_max_delay,
|
|
12
13
|
:circuit_breaker_threshold, :circuit_breaker_timeout,
|
|
@@ -14,7 +15,6 @@ module OpenTrace
|
|
|
14
15
|
:on_drop,
|
|
15
16
|
:compression, :compression_threshold,
|
|
16
17
|
:sql_logging, :sql_duration_threshold_ms,
|
|
17
|
-
:ignore_paths,
|
|
18
18
|
:pool_monitoring, :pool_monitoring_interval,
|
|
19
19
|
:queue_monitoring, :queue_monitoring_interval,
|
|
20
20
|
:request_summary, :timeline, :timeline_max_events,
|
|
@@ -22,7 +22,34 @@ module OpenTrace
|
|
|
22
22
|
:max_payload_bytes,
|
|
23
23
|
:trace_propagation,
|
|
24
24
|
:log_forwarding, :view_tracking, :cache_tracking,
|
|
25
|
-
:deprecation_tracking, :detailed_request_log
|
|
25
|
+
:deprecation_tracking, :detailed_request_log,
|
|
26
|
+
:sample_rate, :sampler, :before_send,
|
|
27
|
+
:sql_normalization, :log_trace_injection,
|
|
28
|
+
:source_context, :before_breadcrumb,
|
|
29
|
+
:pii_scrubbing, :pii_patterns, :pii_disabled_patterns,
|
|
30
|
+
:session_tracking,
|
|
31
|
+
:on_error, :after_send,
|
|
32
|
+
:transport, :socket_path,
|
|
33
|
+
:local_vars_capture,
|
|
34
|
+
:explain_slow_queries, :explain_threshold_ms,
|
|
35
|
+
:runtime_metrics, :runtime_metrics_interval
|
|
36
|
+
|
|
37
|
+
# Custom writers that invalidate the level cache
|
|
38
|
+
attr_reader :min_level, :allowed_levels, :ignore_paths
|
|
39
|
+
|
|
40
|
+
def min_level=(val)
|
|
41
|
+
@min_level = val
|
|
42
|
+
@level_cache = nil
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def allowed_levels=(val)
|
|
46
|
+
@allowed_levels = val
|
|
47
|
+
@level_cache = nil
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def ignore_paths=(val)
|
|
51
|
+
@ignore_paths = val
|
|
52
|
+
end
|
|
26
53
|
|
|
27
54
|
def initialize
|
|
28
55
|
@endpoint = nil
|
|
@@ -50,7 +77,7 @@ module OpenTrace
|
|
|
50
77
|
@compression_threshold = 1024 # only compress payloads > 1KB
|
|
51
78
|
@sql_logging = false
|
|
52
79
|
@sql_duration_threshold_ms = 0.0
|
|
53
|
-
@ignore_paths = []
|
|
80
|
+
@ignore_paths = %w[/up /health /healthz /ping /ready /livez /readyz]
|
|
54
81
|
@pool_monitoring = false
|
|
55
82
|
@pool_monitoring_interval = 30
|
|
56
83
|
@queue_monitoring = false
|
|
@@ -67,6 +94,27 @@ module OpenTrace
|
|
|
67
94
|
@cache_tracking = false
|
|
68
95
|
@deprecation_tracking = false
|
|
69
96
|
@detailed_request_log = false
|
|
97
|
+
@sample_rate = 1.0 # Float 0.0-1.0, default 1.0 (all requests)
|
|
98
|
+
@sampler = nil # Proc(env) -> Float, for per-endpoint rates
|
|
99
|
+
@before_send = nil # Proc(payload) -> payload|nil, filter/drop
|
|
100
|
+
@sql_normalization = true # Normalize SQL queries (replace literals with ?)
|
|
101
|
+
@log_trace_injection = false # Inject trace_id/request_id into Rails logger
|
|
102
|
+
@source_context = false # Capture source code context around errors
|
|
103
|
+
@before_breadcrumb = nil # Proc(Breadcrumb) -> Breadcrumb|nil
|
|
104
|
+
@pii_scrubbing = false # Scrub PII from metadata before sending
|
|
105
|
+
@pii_patterns = nil # Array of additional Regexp patterns
|
|
106
|
+
@pii_disabled_patterns = nil # Array of Symbol pattern names to skip
|
|
107
|
+
@session_tracking = false # Extract session ID from cookies
|
|
108
|
+
@on_error = nil # Proc(exception, metadata) — called on error capture
|
|
109
|
+
@after_send = nil # Proc(batch_size, bytes) — called after successful delivery
|
|
110
|
+
@transport = :http # :http | :unix_socket
|
|
111
|
+
@socket_path = "/tmp/opentrace.sock" # Unix socket path
|
|
112
|
+
@local_vars_capture = false # Capture local variables via explicit binding
|
|
113
|
+
@explain_slow_queries = false # Run EXPLAIN on slow SQL queries
|
|
114
|
+
@explain_threshold_ms = 100.0 # Threshold for EXPLAIN capture
|
|
115
|
+
@runtime_metrics = false # Collect GC/runtime metrics
|
|
116
|
+
@runtime_metrics_interval = 30 # Interval in seconds
|
|
117
|
+
@level_cache = nil
|
|
70
118
|
end
|
|
71
119
|
|
|
72
120
|
def valid?
|
|
@@ -81,12 +129,27 @@ module OpenTrace
|
|
|
81
129
|
LEVELS[min_level.to_s.downcase.to_sym] || 0
|
|
82
130
|
end
|
|
83
131
|
|
|
132
|
+
# Hot path: single hash lookup, zero allocations for known levels.
|
|
133
|
+
# Cache is built lazily on first call and invalidated when
|
|
134
|
+
# min_level or allowed_levels change.
|
|
84
135
|
def level_allowed?(level)
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
136
|
+
cache = @level_cache
|
|
137
|
+
unless cache
|
|
138
|
+
build_level_cache!
|
|
139
|
+
cache = @level_cache
|
|
89
140
|
end
|
|
141
|
+
key = level.to_s.upcase
|
|
142
|
+
result = cache[key]
|
|
143
|
+
return result unless result.nil?
|
|
144
|
+
# Unknown level (e.g. "UNKNOWN"): treat as severity 0
|
|
145
|
+
return false if allowed_levels # not in the allowed list
|
|
146
|
+
0 >= (LEVELS[min_level.to_s.downcase.to_sym] || 0)
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
# Pre-compute the level cache. Called at end of configure block
|
|
150
|
+
# and lazily when settings change afterward.
|
|
151
|
+
def finalize!
|
|
152
|
+
build_level_cache!
|
|
90
153
|
end
|
|
91
154
|
|
|
92
155
|
# Maps OpenTrace min_level to Ruby Logger severity constant.
|
|
@@ -103,5 +166,20 @@ module OpenTrace
|
|
|
103
166
|
def logger_severity
|
|
104
167
|
LEVEL_TO_LOGGER_SEVERITY[min_level.to_s.downcase.to_sym] || 0
|
|
105
168
|
end
|
|
169
|
+
|
|
170
|
+
private
|
|
171
|
+
|
|
172
|
+
def build_level_cache!
|
|
173
|
+
min_int = LEVELS[min_level.to_s.downcase.to_sym] || 0
|
|
174
|
+
if allowed_levels
|
|
175
|
+
@level_cache = allowed_levels.each_with_object({}) do |l, h|
|
|
176
|
+
h[l.to_s.upcase] = true
|
|
177
|
+
end.freeze
|
|
178
|
+
else
|
|
179
|
+
@level_cache = LEVEL_INTS.each_with_object({}) do |(k, v), h|
|
|
180
|
+
h[k] = v >= min_int
|
|
181
|
+
end.freeze
|
|
182
|
+
end
|
|
183
|
+
end
|
|
106
184
|
end
|
|
107
185
|
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenTrace
|
|
4
|
+
module LocalVars
|
|
5
|
+
MAX_VARS = 10
|
|
6
|
+
MAX_VALUE_LENGTH = 500
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
# Capture local variables from an explicit binding.
|
|
11
|
+
# Called by the user in their rescue blocks:
|
|
12
|
+
#
|
|
13
|
+
# rescue => e
|
|
14
|
+
# OpenTrace.capture_binding(e, binding)
|
|
15
|
+
# raise
|
|
16
|
+
# end
|
|
17
|
+
#
|
|
18
|
+
# Returns: Array of { name:, value:, type: } or nil
|
|
19
|
+
def capture(binding_obj)
|
|
20
|
+
return nil unless binding_obj.is_a?(Binding)
|
|
21
|
+
|
|
22
|
+
vars = binding_obj.local_variables.first(MAX_VARS)
|
|
23
|
+
vars.filter_map do |name|
|
|
24
|
+
# Skip internal variables (_, _1, etc.)
|
|
25
|
+
next if name.to_s.start_with?("_")
|
|
26
|
+
|
|
27
|
+
value = binding_obj.local_variable_get(name)
|
|
28
|
+
{
|
|
29
|
+
name: name.to_s,
|
|
30
|
+
value: safe_inspect(value),
|
|
31
|
+
type: value.class.name
|
|
32
|
+
}
|
|
33
|
+
end
|
|
34
|
+
rescue StandardError
|
|
35
|
+
nil
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def safe_inspect(value)
|
|
39
|
+
str = value.inspect
|
|
40
|
+
str.length > MAX_VALUE_LENGTH ? str[0, MAX_VALUE_LENGTH] + "..." : str
|
|
41
|
+
rescue StandardError
|
|
42
|
+
"#<uninspectable>"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
data/lib/opentrace/middleware.rb
CHANGED
|
@@ -13,6 +13,13 @@ module OpenTrace
|
|
|
13
13
|
# When OpenTrace is disabled, pass through with zero overhead
|
|
14
14
|
return @app.call(env) unless OpenTrace.enabled?
|
|
15
15
|
|
|
16
|
+
# Sampling: skip ALL Fiber-local setup for unsampled requests.
|
|
17
|
+
# Subscribers check Fiber-locals and return instantly when nil.
|
|
18
|
+
unless OpenTrace.sampler.sample?(env)
|
|
19
|
+
OpenTrace.send(:client).stats.increment(:sampled_out)
|
|
20
|
+
return @app.call(env)
|
|
21
|
+
end
|
|
22
|
+
|
|
16
23
|
request_id = env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"]
|
|
17
24
|
OpenTrace.current_request_id = request_id
|
|
18
25
|
Fiber[:opentrace_sql_count] = 0
|
|
@@ -28,6 +35,12 @@ module OpenTrace
|
|
|
28
35
|
Fiber[:opentrace_parent_span_id] = parent_span_id
|
|
29
36
|
end
|
|
30
37
|
|
|
38
|
+
# Session tracking (opt-in)
|
|
39
|
+
if OpenTrace.config.session_tracking
|
|
40
|
+
session_id = extract_session_id(env)
|
|
41
|
+
Fiber[:opentrace_session_id] = session_id if session_id
|
|
42
|
+
end
|
|
43
|
+
|
|
31
44
|
# Create RequestCollector only when features that need it are enabled
|
|
32
45
|
cfg = OpenTrace.config
|
|
33
46
|
needs_collector = cfg.request_summary &&
|
|
@@ -59,6 +72,10 @@ module OpenTrace
|
|
|
59
72
|
Fiber[:opentrace_trace_id] = nil
|
|
60
73
|
Fiber[:opentrace_span_id] = nil
|
|
61
74
|
Fiber[:opentrace_parent_span_id] = nil
|
|
75
|
+
Fiber[:opentrace_transaction_name] = nil
|
|
76
|
+
Fiber[:opentrace_breadcrumbs] = nil
|
|
77
|
+
Fiber[:opentrace_session_id] = nil
|
|
78
|
+
Fiber[:opentrace_pending_explains] = nil
|
|
62
79
|
OpenTrace.current_request_id = nil
|
|
63
80
|
end
|
|
64
81
|
|
|
@@ -94,6 +111,25 @@ module OpenTrace
|
|
|
94
111
|
[TraceContext.generate_trace_id, nil]
|
|
95
112
|
end
|
|
96
113
|
|
|
114
|
+
def extract_session_id(env)
|
|
115
|
+
# Try Rack session first
|
|
116
|
+
if (session = env["rack.session"])
|
|
117
|
+
return session.id.to_s if session.respond_to?(:id) && session.id
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# Fall back to session cookie
|
|
121
|
+
cookie_name = env.dig("rack.session.options", :key) || "_session_id"
|
|
122
|
+
cookies = env["HTTP_COOKIE"]
|
|
123
|
+
if cookies
|
|
124
|
+
match = cookies.match(/#{Regexp.escape(cookie_name)}=([^;]+)/)
|
|
125
|
+
return match[1] if match
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
nil
|
|
129
|
+
rescue StandardError
|
|
130
|
+
nil
|
|
131
|
+
end
|
|
132
|
+
|
|
97
133
|
def current_rss_mb
|
|
98
134
|
if RUBY_PLATFORM.include?("linux")
|
|
99
135
|
# Linux: read from /proc — no fork, ~10μs
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module OpenTrace
|
|
4
|
+
# Materializes deferred log entries (frozen Arrays) into payload Hashes.
|
|
5
|
+
# All heavy work (context merge, timestamp formatting, Hash building)
|
|
6
|
+
# runs on the background dispatch thread, keeping the request thread fast.
|
|
7
|
+
module PayloadBuilder
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def materialize(entry, config)
|
|
11
|
+
if entry.is_a?(Array)
|
|
12
|
+
entry[0] == :request ? materialize_request(entry, config) : materialize_log(entry, config)
|
|
13
|
+
elsif entry.is_a?(Hash)
|
|
14
|
+
entry # legacy direct payload
|
|
15
|
+
end
|
|
16
|
+
rescue StandardError
|
|
17
|
+
nil
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def materialize_log(entry, config)
|
|
21
|
+
ts, level, message, metadata, ctx, request_id, trace_id,
|
|
22
|
+
span_id, parent_span_id, req_summary, event_type = entry
|
|
23
|
+
|
|
24
|
+
meta = ctx.is_a?(Hash) ? ctx.dup : {}
|
|
25
|
+
meta.merge!(metadata) if metadata.is_a?(Hash)
|
|
26
|
+
|
|
27
|
+
static_ctx = OpenTrace.send(:static_context)
|
|
28
|
+
static_ctx.each { |k, v| meta[k] ||= v }
|
|
29
|
+
meta[:request_id] ||= request_id if request_id
|
|
30
|
+
|
|
31
|
+
# Extract trace_id from metadata if user provided it there
|
|
32
|
+
meta_trace_id = meta.delete(:trace_id)
|
|
33
|
+
effective_trace_id = meta_trace_id || trace_id
|
|
34
|
+
|
|
35
|
+
payload = {
|
|
36
|
+
timestamp: format_timestamp(ts),
|
|
37
|
+
level: level.to_s.upcase,
|
|
38
|
+
service: config.service,
|
|
39
|
+
environment: config.environment,
|
|
40
|
+
message: message.to_s,
|
|
41
|
+
metadata: meta.compact
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
payload[:event_type] = event_type.to_s if event_type
|
|
45
|
+
payload[:trace_id] = effective_trace_id.to_s if effective_trace_id
|
|
46
|
+
payload[:span_id] = span_id if span_id
|
|
47
|
+
payload[:parent_span_id] = parent_span_id if parent_span_id
|
|
48
|
+
payload[:request_summary] = req_summary if req_summary
|
|
49
|
+
payload
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def materialize_request(entry, config)
|
|
53
|
+
_, started, finished, controller, action, method, path, status,
|
|
54
|
+
exc_class, exc_message, exc_backtrace, request_id, trace_id,
|
|
55
|
+
span_id, parent_span_id, cached_ctx, collector, extra = entry
|
|
56
|
+
|
|
57
|
+
duration_ms = (finished && started) ? (finished - started) * 1000.0 : 0.0
|
|
58
|
+
|
|
59
|
+
meta = cached_ctx.is_a?(Hash) ? cached_ctx.dup : {}
|
|
60
|
+
meta.merge!(extra) if extra.is_a?(Hash)
|
|
61
|
+
|
|
62
|
+
static_ctx = OpenTrace.send(:static_context)
|
|
63
|
+
static_ctx.each { |k, v| meta[k] ||= v }
|
|
64
|
+
meta[:request_id] ||= request_id if request_id
|
|
65
|
+
|
|
66
|
+
if cached_ctx.is_a?(Hash) && cached_ctx.key?(:user_id)
|
|
67
|
+
meta[:user_id] = cached_ctx[:user_id]
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
if exc_class
|
|
71
|
+
meta[:exception_class] = exc_class
|
|
72
|
+
meta[:exception_message] = exc_message&.slice(0, 500)
|
|
73
|
+
if exc_backtrace
|
|
74
|
+
cleaned = clean_backtrace(exc_backtrace)
|
|
75
|
+
meta[:backtrace] = cleaned.first(15)
|
|
76
|
+
meta[:error_fingerprint] = OpenTrace.send(:compute_error_fingerprint, exc_class, cleaned)
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Run deferred EXPLAIN on background thread
|
|
81
|
+
if extra.is_a?(Hash) && extra[:pending_explains] && defined?(ActiveRecord::Base)
|
|
82
|
+
explain_results = run_pending_explains(extra.delete(:pending_explains))
|
|
83
|
+
meta[:explain_plans] = explain_results unless explain_results.empty?
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# Build request_summary from collector
|
|
87
|
+
request_summary = nil
|
|
88
|
+
if collector
|
|
89
|
+
summary = collector.summary
|
|
90
|
+
request_summary = build_request_summary(collector, summary, controller, action, method, path, status, duration_ms)
|
|
91
|
+
else
|
|
92
|
+
# No collector — include request identity in metadata
|
|
93
|
+
meta[:controller] = controller
|
|
94
|
+
meta[:action] = action
|
|
95
|
+
meta[:method] = method
|
|
96
|
+
meta[:path] = path
|
|
97
|
+
meta[:status] = status
|
|
98
|
+
meta[:duration_ms] = duration_ms.round(1)
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
level = if exc_class
|
|
102
|
+
"ERROR"
|
|
103
|
+
elsif status.to_i >= 500
|
|
104
|
+
"ERROR"
|
|
105
|
+
elsif status.to_i >= 400
|
|
106
|
+
"WARN"
|
|
107
|
+
else
|
|
108
|
+
"INFO"
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
# Use custom transaction name if set
|
|
112
|
+
transaction_name = meta.delete(:transaction_name)
|
|
113
|
+
message = if transaction_name
|
|
114
|
+
"#{transaction_name} #{status} #{duration_ms.round(1)}ms"
|
|
115
|
+
else
|
|
116
|
+
"#{method} #{path} #{status} #{duration_ms.round(1)}ms"
|
|
117
|
+
end
|
|
118
|
+
meta[:transaction_name] = transaction_name if transaction_name
|
|
119
|
+
|
|
120
|
+
payload = {
|
|
121
|
+
timestamp: format_timestamp(started),
|
|
122
|
+
level: level,
|
|
123
|
+
service: config.service,
|
|
124
|
+
environment: config.environment,
|
|
125
|
+
message: message,
|
|
126
|
+
metadata: meta.compact
|
|
127
|
+
}
|
|
128
|
+
payload[:trace_id] = trace_id.to_s if trace_id
|
|
129
|
+
payload[:span_id] = span_id if span_id
|
|
130
|
+
payload[:parent_span_id] = parent_span_id if parent_span_id
|
|
131
|
+
payload[:request_summary] = request_summary if request_summary
|
|
132
|
+
payload
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def format_timestamp(ts)
|
|
136
|
+
case ts
|
|
137
|
+
when Float
|
|
138
|
+
Time.at(ts).utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ")
|
|
139
|
+
when Time
|
|
140
|
+
ts.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ")
|
|
141
|
+
else
|
|
142
|
+
Time.now.utc.strftime("%Y-%m-%dT%H:%M:%S.%6NZ")
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def clean_backtrace(backtrace)
|
|
147
|
+
if defined?(::Rails) && ::Rails.respond_to?(:backtrace_cleaner)
|
|
148
|
+
::Rails.backtrace_cleaner.clean(backtrace)
|
|
149
|
+
else
|
|
150
|
+
backtrace.reject { |line| line.include?("/gems/") }
|
|
151
|
+
end
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def run_pending_explains(pending)
|
|
155
|
+
pending.filter_map do |entry|
|
|
156
|
+
plan = run_explain(entry[:sql])
|
|
157
|
+
next unless plan
|
|
158
|
+
{
|
|
159
|
+
sql: entry[:sql].to_s.slice(0, 500),
|
|
160
|
+
duration_ms: entry[:duration_ms],
|
|
161
|
+
name: entry[:name],
|
|
162
|
+
explain_plan: plan
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
rescue StandardError
|
|
166
|
+
[]
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def run_explain(sql)
|
|
170
|
+
ActiveRecord::Base.connection_pool.with_connection do |conn|
|
|
171
|
+
result = conn.execute("EXPLAIN #{sql}")
|
|
172
|
+
rows = result.respond_to?(:rows) ? result.rows : result.map(&:values)
|
|
173
|
+
rows.flatten.join("\n").slice(0, 2000)
|
|
174
|
+
end
|
|
175
|
+
rescue StandardError
|
|
176
|
+
nil
|
|
177
|
+
end
|
|
178
|
+
|
|
179
|
+
def build_request_summary(collector, summary, controller, action, method, path, status, duration_ms)
|
|
180
|
+
rs = {
|
|
181
|
+
controller: controller,
|
|
182
|
+
action: action,
|
|
183
|
+
method: method,
|
|
184
|
+
path: path,
|
|
185
|
+
status: status,
|
|
186
|
+
duration_ms: duration_ms.round(1),
|
|
187
|
+
sql_count: summary[:sql_query_count],
|
|
188
|
+
sql_total_ms: summary[:sql_total_ms],
|
|
189
|
+
sql_slowest_ms: summary[:sql_slowest_ms],
|
|
190
|
+
sql_slowest_name: summary[:sql_slowest_name],
|
|
191
|
+
n_plus_one: summary[:n_plus_one_warning] || false,
|
|
192
|
+
view_count: summary[:view_render_count],
|
|
193
|
+
view_total_ms: summary[:view_total_ms],
|
|
194
|
+
view_slowest_ms: summary[:view_slowest_ms],
|
|
195
|
+
view_slowest_template: summary[:view_slowest_template],
|
|
196
|
+
cache_reads: summary[:cache_reads],
|
|
197
|
+
cache_hits: summary[:cache_hits],
|
|
198
|
+
cache_writes: summary[:cache_writes],
|
|
199
|
+
cache_hit_ratio: summary[:cache_hit_ratio],
|
|
200
|
+
http_external_count: summary[:http_external_count],
|
|
201
|
+
http_external_total_ms: summary[:http_external_total_ms],
|
|
202
|
+
http_slowest_ms: summary[:http_slowest_ms],
|
|
203
|
+
http_slowest_host: summary[:http_slowest_host],
|
|
204
|
+
memory_before_mb: summary[:memory_before_mb],
|
|
205
|
+
memory_after_mb: summary[:memory_after_mb],
|
|
206
|
+
memory_delta_mb: summary[:memory_delta_mb],
|
|
207
|
+
timeline: summary[:timeline]
|
|
208
|
+
}.compact
|
|
209
|
+
|
|
210
|
+
# Compute time breakdown
|
|
211
|
+
if duration_ms > 0
|
|
212
|
+
sql_pct = [((collector.sql_total_ms / duration_ms) * 100).round(1), 100.0].min
|
|
213
|
+
view_pct = [((collector.view_total_ms / duration_ms) * 100).round(1), 100.0].min
|
|
214
|
+
http_pct = collector.http_count > 0 ? [((collector.http_total_ms / duration_ms) * 100).round(1), 100.0].min : 0.0
|
|
215
|
+
other_pct = [100 - sql_pct - view_pct - http_pct, 0].max.round(1)
|
|
216
|
+
rs[:time_breakdown] = {
|
|
217
|
+
sql_pct: sql_pct,
|
|
218
|
+
view_pct: view_pct,
|
|
219
|
+
http_pct: http_pct,
|
|
220
|
+
other_pct: other_pct
|
|
221
|
+
}
|
|
222
|
+
end
|
|
223
|
+
|
|
224
|
+
rs
|
|
225
|
+
end
|
|
226
|
+
end
|
|
227
|
+
end
|