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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: fd043296872a0f5241acc4f8331bc2d6c6f7e39628e0501fc6e0480af7c423f9
4
- data.tar.gz: b37915c9ed52898d6607a668c264c573f8d2012d937ec4053712ff2c65fb741e
3
+ metadata.gz: f91e8fcf70aeeb601265b588138c85d00c5a96d034278caa4226072eb75c440d
4
+ data.tar.gz: ed9d094bf4af6d4545f70d464753bfd594e4d0ddf5758a977e6205bc2d6ad4ef
5
5
  SHA512:
6
- metadata.gz: a9567eefab80204781b112f43f915a755ad55327eef2255b7b9c655da9eb36e4eed55c6617e1711d00bbd74ec9dd1f88332b0f0c0c4e2e0562e75a1fdcb8a9fe
7
- data.tar.gz: 11bf43482a27798a4bcf20c7cb700a565692d2b863d3011feb403df97e039b2b75cd35e0e53f12d7b17a107b7d07ef1959c0539d9507397376bb8901afcb9346
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
@@ -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
- # 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
@@ -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
 
@@ -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,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
- if allowed_levels
86
- allowed_levels.map { |l| l.to_s.upcase }.include?(level.to_s.upcase)
87
- else
88
- (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
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
@@ -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