opentrace 0.7.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 +140 -27
- data/lib/opentrace/config.rb +96 -11
- data/lib/opentrace/local_vars.rb +45 -0
- data/lib/opentrace/middleware.rb +49 -7
- data/lib/opentrace/payload_builder.rb +227 -0
- data/lib/opentrace/pii_scrubber.rb +64 -0
- data/lib/opentrace/rails.rb +194 -221
- data/lib/opentrace/request_collector.rb +48 -10
- 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
|
@@ -11,14 +11,14 @@ module OpenTrace
|
|
|
11
11
|
class Client
|
|
12
12
|
MAX_QUEUE_SIZE = 1000
|
|
13
13
|
PAYLOAD_MAX_BYTES = 262_144 # 256 KB (default; use config.max_payload_bytes to override)
|
|
14
|
-
POLL_INTERVAL = 0.05 # 50ms
|
|
15
14
|
MAX_RATE_LIMIT_BACKOFF = 60 # Cap Retry-After at 60 seconds
|
|
16
15
|
API_VERSION = 1
|
|
17
16
|
|
|
18
17
|
attr_reader :stats
|
|
19
18
|
|
|
20
|
-
def initialize(config)
|
|
19
|
+
def initialize(config, sampler: nil)
|
|
21
20
|
@config = config
|
|
21
|
+
@sampler = sampler
|
|
22
22
|
@queue = Thread::Queue.new
|
|
23
23
|
@mutex = Mutex.new
|
|
24
24
|
@thread = nil
|
|
@@ -95,12 +95,13 @@ module OpenTrace
|
|
|
95
95
|
end
|
|
96
96
|
|
|
97
97
|
def reset_after_fork!
|
|
98
|
-
# After fork, the old thread/queue/mutex from the parent are dead.
|
|
98
|
+
# After fork, the old thread/queue/mutex/connection from the parent are dead.
|
|
99
99
|
# Re-create everything cleanly in the child process.
|
|
100
100
|
@pid = Process.pid
|
|
101
101
|
@queue = Thread::Queue.new
|
|
102
102
|
@mutex = Mutex.new
|
|
103
103
|
@thread = nil
|
|
104
|
+
@http = nil # Parent's connection is unusable after fork
|
|
104
105
|
@circuit_breaker.reset!
|
|
105
106
|
@rate_limit_until = nil
|
|
106
107
|
@auth_suspended = false
|
|
@@ -140,9 +141,21 @@ 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
|
|
157
|
+
ensure
|
|
158
|
+
close_http
|
|
146
159
|
end
|
|
147
160
|
|
|
148
161
|
def rate_limited?
|
|
@@ -188,16 +201,14 @@ module OpenTrace
|
|
|
188
201
|
end
|
|
189
202
|
|
|
190
203
|
def pop_with_timeout(timeout)
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
sleep(POLL_INTERVAL)
|
|
198
|
-
end
|
|
204
|
+
if timeout <= 0
|
|
205
|
+
# Deadline already passed — still try a non-blocking pop in case
|
|
206
|
+
# items arrived while we were busy (e.g. during version check).
|
|
207
|
+
@queue.pop(true)
|
|
208
|
+
else
|
|
209
|
+
@queue.pop(timeout: timeout)
|
|
199
210
|
end
|
|
200
|
-
rescue ClosedQueueError
|
|
211
|
+
rescue ThreadError, ClosedQueueError
|
|
201
212
|
nil
|
|
202
213
|
end
|
|
203
214
|
|
|
@@ -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
|
|
@@ -330,7 +363,6 @@ module OpenTrace
|
|
|
330
363
|
end
|
|
331
364
|
|
|
332
365
|
def http_post(json, batch_id: nil)
|
|
333
|
-
http = build_http(@uri)
|
|
334
366
|
request = Net::HTTP::Post.new(@uri.request_uri)
|
|
335
367
|
request["Authorization"] = "Bearer #{@config.api_key}"
|
|
336
368
|
request["Content-Type"] = "application/json"
|
|
@@ -345,7 +377,37 @@ module OpenTrace
|
|
|
345
377
|
request.body = json
|
|
346
378
|
end
|
|
347
379
|
|
|
348
|
-
|
|
380
|
+
persistent_http.request(request)
|
|
381
|
+
rescue IOError, Errno::EPIPE, Errno::ECONNRESET, Net::OpenTimeout => _e
|
|
382
|
+
# Connection went stale — reset and retry once
|
|
383
|
+
close_http
|
|
384
|
+
persistent_http.request(request)
|
|
385
|
+
end
|
|
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
|
|
349
411
|
end
|
|
350
412
|
|
|
351
413
|
def retryable_response?(response)
|
|
@@ -359,13 +421,27 @@ module OpenTrace
|
|
|
359
421
|
delay + jitter
|
|
360
422
|
end
|
|
361
423
|
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
http
|
|
366
|
-
|
|
367
|
-
http
|
|
368
|
-
http
|
|
424
|
+
# Returns a persistent Net::HTTP connection, creating one if needed.
|
|
425
|
+
# Only called from the dispatch thread — no synchronization needed.
|
|
426
|
+
def persistent_http
|
|
427
|
+
return @http if @http&.started?
|
|
428
|
+
|
|
429
|
+
@http = Net::HTTP.new(@uri.host, @uri.port)
|
|
430
|
+
@http.use_ssl = (@uri.scheme == "https")
|
|
431
|
+
@http.open_timeout = @config.timeout
|
|
432
|
+
@http.read_timeout = @config.timeout
|
|
433
|
+
@http.write_timeout = @config.timeout
|
|
434
|
+
@http.keep_alive_timeout = 30
|
|
435
|
+
@http.start
|
|
436
|
+
@http
|
|
437
|
+
end
|
|
438
|
+
|
|
439
|
+
def close_http
|
|
440
|
+
@http&.finish
|
|
441
|
+
rescue IOError
|
|
442
|
+
# Already closed
|
|
443
|
+
ensure
|
|
444
|
+
@http = nil
|
|
369
445
|
end
|
|
370
446
|
|
|
371
447
|
def gzip_compress(string)
|
|
@@ -377,6 +453,22 @@ module OpenTrace
|
|
|
377
453
|
io.string
|
|
378
454
|
end
|
|
379
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
|
+
|
|
380
472
|
def fire_on_drop(count, reason)
|
|
381
473
|
@config.on_drop&.call(count, reason)
|
|
382
474
|
rescue StandardError
|
|
@@ -421,19 +513,40 @@ module OpenTrace
|
|
|
421
513
|
end
|
|
422
514
|
|
|
423
515
|
@compatibility_checked = true
|
|
424
|
-
rescue
|
|
425
|
-
# Server might not support /api/version yet — that's fine
|
|
516
|
+
rescue Exception # rubocop:disable Lint/RescueException
|
|
517
|
+
# Server might not support /api/version yet — that's fine.
|
|
518
|
+
# Broad rescue: this is best-effort and must never kill the dispatch loop.
|
|
426
519
|
@compatibility_checked = true
|
|
427
520
|
end
|
|
428
521
|
|
|
522
|
+
# One-shot GET for version check. Uses a throwaway connection
|
|
523
|
+
# so a failure doesn't poison the persistent connection.
|
|
429
524
|
def http_get(uri)
|
|
430
|
-
http =
|
|
525
|
+
http = Net::HTTP.new(uri.host, uri.port)
|
|
526
|
+
http.use_ssl = (uri.scheme == "https")
|
|
527
|
+
http.open_timeout = @config.timeout
|
|
528
|
+
http.read_timeout = @config.timeout
|
|
529
|
+
http.write_timeout = @config.timeout
|
|
431
530
|
request = Net::HTTP::Get.new(uri.request_uri)
|
|
432
531
|
request["User-Agent"] = "opentrace-ruby/#{OpenTrace::VERSION}"
|
|
433
532
|
request["Authorization"] = "Bearer #{@config.api_key}"
|
|
434
533
|
http.request(request)
|
|
435
534
|
end
|
|
436
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
|
+
|
|
437
550
|
def truncate_payload(payload)
|
|
438
551
|
meta = payload[:metadata]&.dup || {}
|
|
439
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,13 +15,41 @@ 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,
|
|
21
21
|
:memory_tracking, :http_tracking,
|
|
22
22
|
:max_payload_bytes,
|
|
23
|
-
:trace_propagation
|
|
23
|
+
:trace_propagation,
|
|
24
|
+
:log_forwarding, :view_tracking, :cache_tracking,
|
|
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
|
|
24
53
|
|
|
25
54
|
def initialize
|
|
26
55
|
@endpoint = nil
|
|
@@ -30,7 +59,7 @@ module OpenTrace
|
|
|
30
59
|
@timeout = 1.0
|
|
31
60
|
@enabled = true
|
|
32
61
|
@context = nil # nil | Hash | Proc
|
|
33
|
-
@min_level = :
|
|
62
|
+
@min_level = :info
|
|
34
63
|
@allowed_levels = nil # nil = use min_level threshold (backward compatible)
|
|
35
64
|
@hostname = nil
|
|
36
65
|
@pid = nil
|
|
@@ -46,20 +75,46 @@ module OpenTrace
|
|
|
46
75
|
@on_drop = nil # ->(count, reason) { ... }
|
|
47
76
|
@compression = true
|
|
48
77
|
@compression_threshold = 1024 # only compress payloads > 1KB
|
|
49
|
-
@sql_logging =
|
|
78
|
+
@sql_logging = false
|
|
50
79
|
@sql_duration_threshold_ms = 0.0
|
|
51
|
-
@ignore_paths = []
|
|
80
|
+
@ignore_paths = %w[/up /health /healthz /ping /ready /livez /readyz]
|
|
52
81
|
@pool_monitoring = false
|
|
53
82
|
@pool_monitoring_interval = 30
|
|
54
83
|
@queue_monitoring = false
|
|
55
84
|
@queue_monitoring_interval = 60
|
|
56
85
|
@request_summary = true
|
|
57
|
-
@timeline =
|
|
86
|
+
@timeline = false
|
|
58
87
|
@timeline_max_events = 200
|
|
59
88
|
@memory_tracking = false
|
|
60
89
|
@http_tracking = false
|
|
61
90
|
@max_payload_bytes = 262_144 # 256 KB
|
|
62
91
|
@trace_propagation = true
|
|
92
|
+
@log_forwarding = false
|
|
93
|
+
@view_tracking = false
|
|
94
|
+
@cache_tracking = false
|
|
95
|
+
@deprecation_tracking = false
|
|
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
|
|
63
118
|
end
|
|
64
119
|
|
|
65
120
|
def valid?
|
|
@@ -74,12 +129,27 @@ module OpenTrace
|
|
|
74
129
|
LEVELS[min_level.to_s.downcase.to_sym] || 0
|
|
75
130
|
end
|
|
76
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.
|
|
77
135
|
def level_allowed?(level)
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
136
|
+
cache = @level_cache
|
|
137
|
+
unless cache
|
|
138
|
+
build_level_cache!
|
|
139
|
+
cache = @level_cache
|
|
82
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!
|
|
83
153
|
end
|
|
84
154
|
|
|
85
155
|
# Maps OpenTrace min_level to Ruby Logger severity constant.
|
|
@@ -96,5 +166,20 @@ module OpenTrace
|
|
|
96
166
|
def logger_severity
|
|
97
167
|
LEVEL_TO_LOGGER_SEVERITY[min_level.to_s.downcase.to_sym] || 0
|
|
98
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
|
|
99
184
|
end
|
|
100
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
|
@@ -10,6 +10,16 @@ module OpenTrace
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def call(env)
|
|
13
|
+
# When OpenTrace is disabled, pass through with zero overhead
|
|
14
|
+
return @app.call(env) unless OpenTrace.enabled?
|
|
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
|
+
|
|
13
23
|
request_id = env["action_dispatch.request_id"] || env["HTTP_X_REQUEST_ID"]
|
|
14
24
|
OpenTrace.current_request_id = request_id
|
|
15
25
|
Fiber[:opentrace_sql_count] = 0
|
|
@@ -25,15 +35,24 @@ module OpenTrace
|
|
|
25
35
|
Fiber[:opentrace_parent_span_id] = parent_span_id
|
|
26
36
|
end
|
|
27
37
|
|
|
28
|
-
#
|
|
29
|
-
if OpenTrace.
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
+
|
|
44
|
+
# Create RequestCollector only when features that need it are enabled
|
|
45
|
+
cfg = OpenTrace.config
|
|
46
|
+
needs_collector = cfg.request_summary &&
|
|
47
|
+
(cfg.view_tracking || cfg.cache_tracking || cfg.http_tracking ||
|
|
48
|
+
cfg.timeline || cfg.memory_tracking)
|
|
49
|
+
|
|
50
|
+
if needs_collector
|
|
51
|
+
max_timeline = cfg.timeline ? cfg.timeline_max_events : 0
|
|
52
|
+
collector = OpenTrace::RequestCollector.new(max_timeline: max_timeline)
|
|
33
53
|
Fiber[:opentrace_collector] = collector
|
|
34
54
|
|
|
35
|
-
|
|
36
|
-
if OpenTrace.config.memory_tracking
|
|
55
|
+
if cfg.memory_tracking
|
|
37
56
|
collector.memory_before = current_rss_mb
|
|
38
57
|
end
|
|
39
58
|
end
|
|
@@ -53,6 +72,10 @@ module OpenTrace
|
|
|
53
72
|
Fiber[:opentrace_trace_id] = nil
|
|
54
73
|
Fiber[:opentrace_span_id] = nil
|
|
55
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
|
|
56
79
|
OpenTrace.current_request_id = nil
|
|
57
80
|
end
|
|
58
81
|
|
|
@@ -88,6 +111,25 @@ module OpenTrace
|
|
|
88
111
|
[TraceContext.generate_trace_id, nil]
|
|
89
112
|
end
|
|
90
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
|
+
|
|
91
133
|
def current_rss_mb
|
|
92
134
|
if RUBY_PLATFORM.include?("linux")
|
|
93
135
|
# Linux: read from /proc — no fork, ~10μs
|