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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fbe3de5afdb5f92afcef49d368b8f8b3714ca17fe453b168066f53a5b0e5e1ba
4
- data.tar.gz: 8f749e943951c939e7daa8f93a454bb0c89646bc295c0550c680064ebf38db16
3
+ metadata.gz: f91e8fcf70aeeb601265b588138c85d00c5a96d034278caa4226072eb75c440d
4
+ data.tar.gz: ed9d094bf4af6d4545f70d464753bfd594e4d0ddf5758a977e6205bc2d6ad4ef
5
5
  SHA512:
6
- metadata.gz: e3ecb7c4b649951f64e1090bb7ea06da69cb603dcd44534c9a5e71d76eb697239fddf2267cd2a5e0cff2aa1bf86dc78be6733be754df8e5c6a435967d90809a8
7
- data.tar.gz: 3625d9de1cd2dda011f69283b34a17964450cda3c44c0f19431fb450e2a24328f1c260a12ea9929331a54866ac90afd07addd65e16cb2eee9e42a939807f3a69
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
@@ -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
- deadline = Time.now + [timeout, 0].max
192
- loop do
193
- begin
194
- return @queue.pop(true)
195
- rescue ThreadError
196
- return nil if Time.now >= deadline || @queue.closed?
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
- # Apply per-payload truncation
216
- batch = batch.map { |p| fit_payload(p) }.compact
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 = send_with_retry(json)
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
- http.request(request)
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
- def build_http(uri)
363
- http = Net::HTTP.new(uri.host, uri.port)
364
- http.use_ssl = (uri.scheme == "https")
365
- http.open_timeout = @config.timeout
366
- http.read_timeout = @config.timeout
367
- http.write_timeout = @config.timeout
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 StandardError
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 = build_http(uri)
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
 
@@ -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, :min_level, :allowed_levels, :hostname, :pid, :git_sha,
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 = :debug # send everything by default
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 = true
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 = true
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
- if allowed_levels
79
- allowed_levels.map { |l| l.to_s.upcase }.include?(level.to_s.upcase)
80
- else
81
- (LEVELS[level.to_s.downcase.to_sym] || 0) >= min_level_value
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
@@ -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
- # Create RequestCollector for accumulate-and-summarize pattern
29
- if OpenTrace.enabled? && OpenTrace.config.request_summary
30
- collector = OpenTrace::RequestCollector.new(
31
- max_timeline: OpenTrace.config.timeline_max_events
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
- # Memory snapshot before request (opt-in)
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