rails-otel-context 0.9.6 → 0.9.8

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: 6e06fe4be3a8bd4949ad32d7fbe9e3dac1090d9044b997c7427feee131776564
4
- data.tar.gz: 61e3bc83de1debb6c9cafce615a8c7328b751bdbfb2ffa5bdf037cbae1a01a86
3
+ metadata.gz: 0aa66c7dfd0480bd0a9100e00fbea58eaab5ec0153dd5cb4a82b8df840092787
4
+ data.tar.gz: 9743746552b3a934798f2812a61f9d4f72f5c296ba75dae943b9dcb5120e8f5e
5
5
  SHA512:
6
- metadata.gz: 9424d03a617834466c9f52d7fb870f1a5dfdbd39c7966cf128c5df1bd87a497af38baff4be7838a9181f9e3c23e1659299c15b27d01d22d9af295c3cbbe4b4d3
7
- data.tar.gz: 6201a8a0974a549ca93e3587d0fd416f57cc92c3594922d6e72685b9641477d2800ccf2e9b7c5d72d1504bbf877c8a3c47949e272cfbd647a3b697a61de7eaf1
6
+ metadata.gz: 40c59f1db646b5d159e23b36d9f651b4809ee19dc8f797ef9b6a5d261810ccfabc7d50c4460e01ac1b132058e1a28d9312b235d0c66e2f99fa6503f98a5ce4b9
7
+ data.tar.gz: 828f792d02e5ff2c7588d946f8535f5d41f25ad84e55f22854f73e007cb6bf6c39eaf1f05ab40eedae6a37c0d8d5c9e952d127428255221d47bee6d72e51170f
@@ -12,11 +12,12 @@ module RailsOtelContext
12
12
  # Relation#exec_queries. This handles lazy scopes like
13
13
  # Transaction.recent_completed.to_a where the scope method returns before
14
14
  # SQL fires.
15
- module ActiveRecordContext
16
- THREAD_KEY = :_rails_otel_ctx_ar
17
- SCOPE_THREAD_KEY = :_rails_otel_ctx_scope
18
- DB_SLOW_ATTR = 'db.slow'
19
- private_constant :THREAD_KEY, :SCOPE_THREAD_KEY
15
+ module ActiveRecordContext # rubocop:disable Metrics/ModuleLength
16
+ THREAD_KEY = :_rails_otel_ctx_ar
17
+ SCOPE_THREAD_KEY = :_rails_otel_ctx_scope
18
+ PENDING_PREPARE_KEY = :_rails_otel_ctx_pending_prepare_spans
19
+ DB_SLOW_ATTR = 'db.slow'
20
+ private_constant :THREAD_KEY, :SCOPE_THREAD_KEY, :PENDING_PREPARE_KEY
20
21
 
21
22
  # Frozen regex — only the verb regex remains; table extraction uses index+slice.
22
23
  SQL_VERB_RE = /\A(\w+)/i
@@ -102,18 +103,29 @@ module RailsOtelContext
102
103
  ctx[:async] = true if payload[:async]
103
104
  Thread.current[THREAD_KEY] = ctx
104
105
 
106
+ return unless defined?(OpenTelemetry::Trace)
107
+
105
108
  # Enrich the current span directly. When OTel instruments via driver-level
106
109
  # prepend (Trilogy, PG, Mysql2), the span is created BEFORE this notification
107
110
  # fires, so CallContextProcessor#on_start sees nil AR context. Applying here
108
111
  # fixes those spans after the fact.
109
- return unless defined?(OpenTelemetry::Trace)
110
-
111
112
  ActiveRecordContext.apply_to_span(OpenTelemetry::Trace.current_span, ctx)
113
+
114
+ # Retroactively enrich any PREPARE spans that finished before this notification
115
+ # fired. PG's prepared-statement flow sends PREPARE then EXECUTE as separate wire
116
+ # operations; the PREPARE span finishes before sql.active_record starts, so it
117
+ # never sees AR context. CallContextProcessor#on_finish stashes those spans here.
118
+ pending = Thread.current[PENDING_PREPARE_KEY]
119
+ return unless pending
120
+
121
+ pending.each { |s| ActiveRecordContext.retroactively_apply_to_span(s, ctx) }
122
+ Thread.current[PENDING_PREPARE_KEY] = nil
112
123
  end
113
124
 
114
125
  def finish(_name, _id, _payload)
115
126
  ensure
116
127
  Thread.current[THREAD_KEY] = nil
128
+ Thread.current[PENDING_PREPARE_KEY] = nil # clear any leftovers from skipped notifications
117
129
  end
118
130
  end
119
131
 
@@ -173,8 +185,16 @@ module RailsOtelContext
173
185
  end
174
186
 
175
187
  def clear!
176
- Thread.current[THREAD_KEY] = nil
177
- Thread.current[SCOPE_THREAD_KEY] = nil
188
+ Thread.current[THREAD_KEY] = nil
189
+ Thread.current[SCOPE_THREAD_KEY] = nil
190
+ Thread.current[PENDING_PREPARE_KEY] = nil
191
+ end
192
+
193
+ # Called from CallContextProcessor#on_finish when a PREPARE span finishes
194
+ # before its sql.active_record notification. Stashed spans are flushed by
195
+ # Subscriber#start when the notification fires.
196
+ def stash_prepare_span(span)
197
+ (Thread.current[PENDING_PREPARE_KEY] ||= []) << span
178
198
  end
179
199
 
180
200
  # Test helpers: set AR context directly for unit tests.
@@ -186,6 +206,34 @@ module RailsOtelContext
186
206
  Thread.current[SCOPE_THREAD_KEY] = scope_name
187
207
  end
188
208
 
209
+ # Retroactively applies AR context to a finished span (e.g. PREPARE spans that
210
+ # finished before sql.active_record fired). Uses direct @attributes mutation
211
+ # because span.recording? is false — set_attribute would be a no-op.
212
+ def retroactively_apply_to_span(span, ctx)
213
+ attrs = span.instance_variable_get(:@attributes)
214
+ return unless attrs.respond_to?(:store)
215
+
216
+ attrs.store(AR_MODEL_ATTR, ctx[:model_name]) if ctx[:model_name]
217
+ attrs.store(AR_METHOD_ATTR, ctx[:method_name]) if ctx[:method_name]
218
+ attrs.store(AR_SCOPE_ATTR, ctx[:scope_name]) if ctx[:scope_name]
219
+
220
+ formatter = RailsOtelContext.configuration.span_name_formatter
221
+ return unless formatter && span.respond_to?(:name) && attrs.key?('db.system')
222
+
223
+ ar_ctx = ctx.dup
224
+ ar_ctx[:code_namespace] = attrs['code.namespace']
225
+ ar_ctx[:code_function] = attrs['code.function']
226
+
227
+ original_name = span.name
228
+ new_name = formatter.call(original_name, ar_ctx)
229
+ return unless new_name && new_name != original_name
230
+
231
+ attrs.store(ORIG_NAME_ATTR, original_name)
232
+ span.instance_variable_set(:@name, new_name)
233
+ rescue StandardError
234
+ nil
235
+ end
236
+
189
237
  # Applies AR context directly to a span. Used by Subscriber#start to enrich spans
190
238
  # created by driver-level OTel instrumentation (Trilogy, PG) before our notification
191
239
  # subscriber runs. Also reads code.namespace/code.function already set by
@@ -5,7 +5,14 @@ module RailsOtelContext
5
5
  module Clickhouse
6
6
  module_function
7
7
 
8
- CANDIDATE_METHODS = %i[query select insert execute command].freeze
8
+ # click_house gem v1.x used :query/:select; v2.x uses :select_all/:select_one/:select_value.
9
+ # We list all known variants so install! picks whichever the loaded gem version defines.
10
+ CANDIDATE_METHODS = %i[
11
+ select_all select_one select_value
12
+ insert insert_compact insert_rows
13
+ execute command
14
+ query select
15
+ ].freeze
9
16
  REENTRANCY_KEY = :_rails_otel_ctx_clickhouse_instrumenting
10
17
 
11
18
  def install!(app_root:)
@@ -43,6 +50,52 @@ module RailsOtelContext
43
50
  @patch_modules[key] ||= build_patch_module(methods)
44
51
  end
45
52
 
53
+ # Maps compound gem method names to their SQL verb for span naming.
54
+ # select_all/select_one/select_value → SELECT; insert_* → INSERT.
55
+ METHOD_OP_ALIAS = {
56
+ 'SELECT_ALL' => 'SELECT',
57
+ 'SELECT_ONE' => 'SELECT',
58
+ 'SELECT_VALUE' => 'SELECT',
59
+ 'INSERT_COMPACT' => 'INSERT',
60
+ 'INSERT_ROWS' => 'INSERT'
61
+ }.freeze
62
+
63
+ # Derives a human-readable span name from the SQL statement.
64
+ # Follows OTel DB convention: "{sql_verb} {table}".
65
+ # Falls back to "{method_op} clickhouse" when the statement is absent
66
+ # or cannot be parsed (e.g. raw ClickHouse commands with no FROM clause).
67
+ #
68
+ # Accepts an optional pre-parsed +table_name+ to avoid a second regex scan
69
+ # when the caller already holds the result of parse_table.
70
+ def span_name_for(statement, method_op, table_name: nil)
71
+ effective_op = METHOD_OP_ALIAS.fetch(method_op, method_op)
72
+ return "#{effective_op} clickhouse" unless statement.is_a?(String)
73
+
74
+ sql_verb = statement.lstrip.split(/\s/, 2).first&.upcase
75
+ table_name = parse_table(statement).last if table_name.nil?
76
+
77
+ if sql_verb && table_name
78
+ "#{sql_verb} #{table_name}"
79
+ elsif sql_verb
80
+ "#{sql_verb} clickhouse"
81
+ else
82
+ "#{effective_op} clickhouse"
83
+ end
84
+ end
85
+
86
+ # Returns [db_name, table_name] extracted from the SQL statement.
87
+ # db_name is the schema prefix (e.g. "mailgun_analytics"), nil when absent.
88
+ # table_name is the bare table (e.g. "mailgun_events"), nil when not found.
89
+ def parse_table(statement)
90
+ return [nil, nil] unless statement.is_a?(String)
91
+
92
+ qualified = statement.match(/(?:\bFROM\b|\bINTO\b|\bUPDATE\b)\s+([\w.]+)/i)&.captures&.first
93
+ return [nil, nil] unless qualified
94
+
95
+ parts = qualified.split('.')
96
+ parts.size > 1 ? [parts[0..-2].join('.'), parts.last] : [nil, parts.first]
97
+ end
98
+
46
99
  def build_patch_module(methods)
47
100
  mod = Module.new do
48
101
  class << self
@@ -55,11 +108,12 @@ module RailsOtelContext
55
108
  end
56
109
  end
57
110
 
58
- # AR context and span renaming handled by CallContextProcessor.apply_db_context.
59
111
  reentrancy_key = RailsOtelContext::Adapters::Clickhouse::REENTRANCY_KEY
60
112
 
61
113
  methods.each do |method_name|
62
- operation = method_name.to_s.upcase.freeze
114
+ method_op = RailsOtelContext::Adapters::Clickhouse::METHOD_OP_ALIAS
115
+ .fetch(method_name.to_s.upcase, method_name.to_s.upcase)
116
+ .freeze
63
117
 
64
118
  define_method(method_name) do |*args, &block|
65
119
  return super(*args, &block) if Thread.current[reentrancy_key]
@@ -67,16 +121,45 @@ module RailsOtelContext
67
121
  site = mod.call_site_for_app
68
122
  statement = args.first.is_a?(String) ? args.first : nil
69
123
 
124
+ # Parse table once — span_name_for accepts the pre-parsed value to skip
125
+ # the internal regex scan, and we reuse db_name for db.name attribute.
126
+ db_name, table_name = RailsOtelContext::Adapters::Clickhouse.parse_table(statement)
127
+ sql_verb = statement ? statement.lstrip.split(/\s/, 2).first&.upcase || method_op : method_op
128
+ span_name = RailsOtelContext::Adapters::Clickhouse.span_name_for(
129
+ statement, method_op, table_name: table_name
130
+ )
131
+
70
132
  tracer = OpenTelemetry.tracer_provider.tracer('rails-otel-context-clickhouse')
71
133
  Thread.current[reentrancy_key] = true
72
134
 
73
- tracer.in_span("#{operation} clickhouse", kind: :client) do |span|
74
- span.set_attribute('db.system', 'clickhouse')
75
- span.set_attribute('db.operation', operation)
76
- span.set_attribute('db.statement', statement) if statement
135
+ tracer.in_span(span_name, kind: :client) do |span|
136
+ span.set_attribute('db.system', 'clickhouse')
137
+ span.set_attribute('db.operation', sql_verb)
138
+ span.set_attribute('db.statement', statement) if statement
139
+ span.set_attribute('db.name', db_name) if db_name
140
+ span.set_attribute('db.sql.table', table_name) if table_name
77
141
 
78
142
  result = super(*args, &block)
79
143
  mod.apply_call_site_to_span(span, site)
144
+
145
+ # ClickHouse spans don't fire sql.active_record notifications, so
146
+ # CallContextProcessor#apply_db_context never runs for them.
147
+ # Apply the span_name_formatter here with a synthetic AR-shaped context
148
+ # built from code.namespace/code.function set by apply_call_site_to_span.
149
+ formatter = RailsOtelContext.configuration.span_name_formatter
150
+ code_ns = formatter && span.respond_to?(:attributes) &&
151
+ span.attributes['code.namespace']
152
+ if code_ns
153
+ fn = span.attributes['code.function']
154
+ ar_ctx = { model_name: code_ns, method_name: fn, scope_name: nil,
155
+ code_namespace: code_ns, code_function: fn }
156
+ new_name = formatter.call(span_name, ar_ctx)
157
+ if new_name && new_name != span_name
158
+ span.set_attribute('l9.orig.name', span_name)
159
+ span.name = new_name
160
+ end
161
+ end
162
+
80
163
  result
81
164
  end
82
165
  ensure
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOtelContext
4
+ module Adapters
5
+ module ConnectionPool
6
+ TRACER_NAME = 'rails_otel_context'
7
+ SPAN_NAME = 'active_record.connection_checkout'
8
+ private_constant :TRACER_NAME, :SPAN_NAME
9
+
10
+ module_function
11
+
12
+ def install!
13
+ return unless defined?(::ActiveRecord::ConnectionAdapters::ConnectionPool)
14
+
15
+ patch_module = patch_module_for
16
+ return if ::ActiveRecord::ConnectionAdapters::ConnectionPool.ancestors.include?(patch_module)
17
+
18
+ ::ActiveRecord::ConnectionAdapters::ConnectionPool.prepend(patch_module)
19
+ end
20
+
21
+ def patch_module_for
22
+ @patch_module ||= build_patch_module
23
+ end
24
+
25
+ def build_patch_module
26
+ # Capture as locals so define_method closes over them — constants declared
27
+ # private_constant are inaccessible from the anonymous module's def scope.
28
+ tracer_name = TRACER_NAME
29
+ span_name = SPAN_NAME
30
+ # Cached lazily on first checkout — tracer_provider may not be fully
31
+ # configured when install! runs at boot.
32
+ cached_tracer = nil
33
+
34
+ Module.new do
35
+ define_method(:checkout) do |checkout_timeout = @checkout_timeout|
36
+ # Rails 7.2+ fast path: pinned connection bypasses pool wait entirely.
37
+ # No span needed — duration would be ~0ms and it's misleading noise.
38
+ return super(checkout_timeout) if @pinned_connection
39
+
40
+ cached_tracer ||= OpenTelemetry.tracer_provider.tracer(tracer_name)
41
+
42
+ cached_tracer.in_span(span_name) do |span|
43
+ result = super(checkout_timeout)
44
+
45
+ # stat acquires the pool's monitor lock and iterates @connections —
46
+ # acceptable for opt-in diagnostics. Avoid enabling this permanently
47
+ # on high-traffic pools where lock contention is already a concern.
48
+ pool_stat = stat
49
+ span.set_attribute('db.pool.size', pool_stat[:size])
50
+ span.set_attribute('db.pool.busy', pool_stat[:busy])
51
+ span.set_attribute('db.pool.idle', pool_stat[:idle])
52
+ span.set_attribute('db.pool.waiting', pool_stat[:waiting])
53
+
54
+ result
55
+ end
56
+ end
57
+ end
58
+ end
59
+ private_class_method :patch_module_for, :build_patch_module
60
+ end
61
+ end
62
+ end
@@ -32,12 +32,11 @@ module RailsOtelContext
32
32
  end
33
33
  end
34
34
 
35
- # AR context and span renaming handled by CallContextProcessor.apply_db_context.
35
+ # Push call-site into FrameContext BEFORE super so the OTel child span
36
+ # created inside super picks it up via CallContextProcessor#on_start.
36
37
  %i[query prepare].each do |method_name|
37
38
  define_method(method_name) do |*args|
38
- result = super(*args)
39
- mod.apply_call_site_to_span(OpenTelemetry::Trace.current_span, mod.call_site_for_app)
40
- result
39
+ mod.with_call_site_frame { super(*args) }
41
40
  end
42
41
  end
43
42
  end
@@ -42,16 +42,14 @@ module RailsOtelContext
42
42
  end
43
43
  end
44
44
 
45
- # AR context and span renaming handled by CallContextProcessor.apply_db_context.
45
+ # Push call-site into FrameContext BEFORE super so the OTel child span
46
+ # created inside super picks it up via CallContextProcessor#on_start.
46
47
  methods.each do |method_name|
47
48
  define_method(method_name) do |*args, &user_block|
48
- # Capture before super: PG yields into a block so the original
49
- # call stack is only visible here, not inside the result block.
50
- site = mod.call_site_for_app
51
-
52
- super(*args) do |result|
53
- mod.apply_call_site_to_span(OpenTelemetry::Trace.current_span, site)
54
- user_block ? user_block.call(result) : result
49
+ mod.with_call_site_frame do
50
+ super(*args) do |result|
51
+ user_block ? user_block.call(result) : result
52
+ end
55
53
  end
56
54
  end
57
55
  end
@@ -32,11 +32,10 @@ module RailsOtelContext
32
32
  end
33
33
  end
34
34
 
35
- # AR context and span renaming handled by CallContextProcessor.apply_db_context.
35
+ # Push call-site into FrameContext BEFORE super so the OTel child span
36
+ # created inside super picks it up via CallContextProcessor#on_start.
36
37
  define_method(:query) do |sql|
37
- result = super(sql)
38
- mod.apply_call_site_to_span(OpenTelemetry::Trace.current_span, mod.call_site_for_app)
39
- result
38
+ mod.with_call_site_frame { super(sql) }
40
39
  end
41
40
  end
42
41
 
@@ -5,6 +5,7 @@ require 'rails_otel_context/adapters/mysql2'
5
5
  require 'rails_otel_context/adapters/trilogy'
6
6
  require 'rails_otel_context/adapters/redis'
7
7
  require 'rails_otel_context/adapters/clickhouse'
8
+ require 'rails_otel_context/adapters/connection_pool'
8
9
 
9
10
  module RailsOtelContext
10
11
  module Adapters
@@ -16,6 +17,7 @@ module RailsOtelContext
16
17
  Trilogy.install!(app_root: app_root)
17
18
  Redis.install!(app_root: app_root) if config.redis_source_enabled
18
19
  Clickhouse.install!(app_root: app_root) if config.clickhouse_enabled
20
+ ConnectionPool.install! if config.connection_pool_tracing_enabled
19
21
  end
20
22
  end
21
23
  end
@@ -0,0 +1,122 @@
1
+ # frozen_string_literal: true
2
+
3
+ module RailsOtelContext
4
+ # Rack middleware that captures request and response bodies as OpenTelemetry
5
+ # span attributes. Works with any OTel Rack instrumentation — the middleware
6
+ # must sit inside (after) the span-creating middleware so current_span is set.
7
+ #
8
+ # Usage:
9
+ # # config/application.rb (or an initializer after OTel is configured)
10
+ # config.middleware.use RailsOtelContext::BodyCapture
11
+ #
12
+ # # With options:
13
+ # config.middleware.use RailsOtelContext::BodyCapture,
14
+ # on_error_only: true,
15
+ # max_bytes: 4096
16
+ #
17
+ # Span attributes set:
18
+ # http.request.body — captured request body (when content type matches)
19
+ # http.response.body — captured response body (when content type matches)
20
+ class BodyCapture
21
+ DEFAULT_CONTENT_TYPES = %w[application/json application/xml text/plain].freeze
22
+ DEFAULT_EXCLUDE_PATHS = %w[/health /ready /metrics].freeze
23
+ DEFAULT_MAX_BYTES = 8_192
24
+ TRUNCATED_SUFFIX = '...[TRUNCATED]'
25
+ private_constant :DEFAULT_CONTENT_TYPES, :DEFAULT_EXCLUDE_PATHS,
26
+ :DEFAULT_MAX_BYTES, :TRUNCATED_SUFFIX
27
+
28
+ def initialize(app, # rubocop:disable Metrics/ParameterLists
29
+ capture_request: true,
30
+ capture_response: true,
31
+ max_bytes: DEFAULT_MAX_BYTES,
32
+ on_error_only: false,
33
+ content_types: DEFAULT_CONTENT_TYPES,
34
+ include_paths: [],
35
+ exclude_paths: DEFAULT_EXCLUDE_PATHS)
36
+ @app = app
37
+ @capture_request = capture_request
38
+ @capture_response = capture_response
39
+ @max_bytes = max_bytes
40
+ @on_error_only = on_error_only
41
+ @content_types = content_types
42
+ @include_paths = include_paths
43
+ @exclude_paths = exclude_paths
44
+ end
45
+
46
+ def call(env)
47
+ return @app.call(env) unless should_capture?(env['PATH_INFO'])
48
+
49
+ request_str = read_request(env)
50
+ status, headers, body = @app.call(env)
51
+
52
+ # With on_error_only, skip body drain on successful responses — no buffering
53
+ # overhead on the happy path. (Synchronous Rack means we know status before
54
+ # deciding to drain, unlike async frameworks that must always buffer.)
55
+ should_record = !@on_error_only || status >= 400
56
+
57
+ if should_record && @capture_response
58
+ body, response_str = drain_response(body, headers)
59
+ end
60
+
61
+ set_span_attributes(request_str, response_str) if should_record
62
+
63
+ [status, headers, body]
64
+ end
65
+
66
+ private
67
+
68
+ def should_capture?(path)
69
+ return false if @exclude_paths.any? { |p| path.start_with?(p) }
70
+ return true if @include_paths.empty?
71
+
72
+ @include_paths.any? { |p| path.start_with?(p) }
73
+ end
74
+
75
+ def allowed_content_type?(content_type)
76
+ return false unless content_type && !content_type.empty?
77
+ return true if @content_types.empty?
78
+
79
+ @content_types.any? { |ct| content_type.include?(ct) }
80
+ end
81
+
82
+ def read_request(env)
83
+ return unless @capture_request
84
+ return unless allowed_content_type?(env['CONTENT_TYPE'])
85
+
86
+ input = env['rack.input']
87
+ return unless input
88
+
89
+ raw = input.read
90
+ input.rewind
91
+ cap(raw)
92
+ end
93
+
94
+ def drain_response(body, headers)
95
+ chunks = body.map { |chunk| chunk }
96
+ body.close if body.respond_to?(:close)
97
+
98
+ content_type = headers['Content-Type'] || headers['content-type']
99
+ body_str = cap(chunks.join) if allowed_content_type?(content_type)
100
+
101
+ [chunks, body_str]
102
+ end
103
+
104
+ def set_span_attributes(request_str, response_str)
105
+ return unless defined?(OpenTelemetry)
106
+
107
+ span = OpenTelemetry::Trace.current_span
108
+ return unless span.context.valid?
109
+
110
+ span.set_attribute('http.request.body', request_str) if request_str
111
+ span.set_attribute('http.response.body', response_str) if response_str
112
+ end
113
+
114
+ def cap(str)
115
+ return nil if str.nil? || str.empty?
116
+ return str if str.bytesize <= @max_bytes
117
+
118
+ # scrub cleans up any incomplete multibyte sequence at the slice boundary
119
+ str.byteslice(0, @max_bytes).scrub + TRUNCATED_SUFFIX
120
+ end
121
+ end
122
+ end
@@ -52,8 +52,14 @@ module RailsOtelContext
52
52
  end
53
53
 
54
54
  def on_finish(span)
55
+ return unless span.respond_to?(:attributes)
56
+
57
+ attrs = span.attributes
58
+ return unless attrs&.key?('db.system')
59
+
60
+ stash_if_prepare_span(span, attrs)
61
+
55
62
  return unless @slow_query_threshold_ms
56
- return unless span.respond_to?(:attributes) && span.attributes&.key?('db.system')
57
63
 
58
64
  start_ns = span.start_timestamp
59
65
  end_ns = span.end_timestamp
@@ -65,8 +71,8 @@ module RailsOtelContext
65
71
  # span.recording? is false here — the span has finished and current_span
66
72
  # has reverted to the HTTP parent. Write directly to the backing attributes
67
73
  # hash so db.slow lands on the actual DB span, not the HTTP parent.
68
- attrs = span.instance_variable_get(:@attributes)
69
- attrs.store(ActiveRecordContext::DB_SLOW_ATTR, true) if attrs.respond_to?(:store)
74
+ raw_attrs = span.instance_variable_get(:@attributes)
75
+ raw_attrs.store(ActiveRecordContext::DB_SLOW_ATTR, true) if raw_attrs.respond_to?(:store)
70
76
  rescue StandardError
71
77
  nil
72
78
  end
@@ -79,20 +85,32 @@ module RailsOtelContext
79
85
 
80
86
  private
81
87
 
88
+ # Stash PREPARE spans so Subscriber#start can retroactively apply AR context
89
+ # once the enclosing sql.active_record notification fires. PG's prepared-statement
90
+ # flow runs PREPARE → EXECUTE as separate wire operations; the PREPARE span
91
+ # finishes before the notification, so on_start never sees AR context for it.
92
+ def stash_if_prepare_span(span, attrs)
93
+ return unless attrs['db.operation'] == 'PREPARE'
94
+ return if attrs.key?('code.activerecord.model')
95
+
96
+ ActiveRecordContext.stash_prepare_span(span)
97
+ end
98
+
82
99
  def apply_call_context(span)
83
100
  # Explicit override: app code called FrameContext.with_frame (or Frameable).
84
101
  # O(1) — no stack walk. Takes priority over automatic detection.
85
- pushed = FrameContext.current
86
- if pushed
87
- span.set_attribute('code.namespace', pushed[:class_name])
88
- span.set_attribute('code.function', pushed[:method_name]) if pushed[:method_name]
89
- return
90
- end
102
+ # DB adapters push filepath/lineno too so the child span gets full call-site info.
103
+ site = FrameContext.current
104
+ unless site
105
+ # Default: walk the call stack to find the nearest app-code frame.
106
+ return unless Thread.respond_to?(:each_caller_location)
91
107
 
92
- # Default: walk the call stack to find the nearest app-code frame.
93
- return unless Thread.respond_to?(:each_caller_location)
108
+ site = call_site_for_app
109
+ end
110
+ set_call_site_attributes(span, site)
111
+ end
94
112
 
95
- site = call_site_for_app
113
+ def set_call_site_attributes(span, site)
96
114
  return unless site
97
115
 
98
116
  span.set_attribute('code.namespace', site[:class_name])
@@ -4,6 +4,7 @@ module RailsOtelContext
4
4
  class Configuration
5
5
  attr_accessor :redis_source_enabled,
6
6
  :clickhouse_enabled,
7
+ :connection_pool_tracing_enabled,
7
8
  :span_name_formatter,
8
9
  :slow_query_threshold_ms
9
10
 
@@ -16,6 +17,7 @@ module RailsOtelContext
16
17
  def initialize
17
18
  @redis_source_enabled = false
18
19
  @clickhouse_enabled = true
20
+ @connection_pool_tracing_enabled = false
19
21
  @span_name_formatter = nil
20
22
  @custom_span_attributes = nil
21
23
  @request_context_enabled = false
@@ -27,17 +27,19 @@ module RailsOtelContext
27
27
  class << self
28
28
  # Pushes +class_name+/+method_name+ for the duration of the block,
29
29
  # restoring whatever was pushed before (supports nesting).
30
- def with_frame(class_name:, method_name:)
30
+ # Optional +filepath:+ and +lineno:+ are carried through to the span
31
+ # processor so DB adapter call-site info survives the span lifecycle.
32
+ def with_frame(class_name:, method_name:, filepath: nil, lineno: nil)
31
33
  prev = Thread.current[FRAME_KEY]
32
- Thread.current[FRAME_KEY] = { class_name: class_name, method_name: method_name }.freeze
34
+ Thread.current[FRAME_KEY] = build_frame(class_name, method_name, filepath, lineno)
33
35
  yield
34
36
  ensure
35
37
  Thread.current[FRAME_KEY] = prev
36
38
  end
37
39
 
38
40
  # Manual push without a block. Caller must call +pop+ in an ensure.
39
- def push(class_name:, method_name:)
40
- Thread.current[FRAME_KEY] = { class_name: class_name, method_name: method_name }.freeze
41
+ def push(class_name:, method_name:, filepath: nil, lineno: nil)
42
+ Thread.current[FRAME_KEY] = build_frame(class_name, method_name, filepath, lineno)
41
43
  end
42
44
 
43
45
  # Clears the pushed frame. Pair with +push+ in an ensure block.
@@ -51,6 +53,15 @@ module RailsOtelContext
51
53
  end
52
54
 
53
55
  alias clear! pop
56
+
57
+ private
58
+
59
+ def build_frame(class_name, method_name, filepath, lineno)
60
+ frame = { class_name: class_name, method_name: method_name }
61
+ frame[:filepath] = filepath if filepath
62
+ frame[:lineno] = lineno if lineno
63
+ frame.freeze
64
+ end
54
65
  end
55
66
  end
56
67
 
@@ -46,6 +46,22 @@ module RailsOtelContext
46
46
  span.set_attribute('code.lineno', site[:lineno]) if site[:lineno]
47
47
  end
48
48
 
49
+ # Wraps a block with the nearest app-code frame pushed into FrameContext.
50
+ # Used by DB adapters to make the call-site available to
51
+ # CallContextProcessor#on_start for the child span created inside the block.
52
+ # Uses with_frame (not push/pop) so nested frames are correctly restored.
53
+ def with_call_site_frame(&)
54
+ site = call_site_for_app
55
+ if site
56
+ FrameContext.with_frame(
57
+ class_name: site[:class_name], method_name: site[:method_name],
58
+ filepath: site[:filepath], lineno: site[:lineno], &
59
+ )
60
+ else
61
+ yield
62
+ end
63
+ end
64
+
49
65
  # Legacy helper kept for Redis and ClickHouse adapters that only need filepath + lineno.
50
66
  # Migrate those adapters to call_site_for_app + apply_call_site_to_span to remove this.
51
67
  def source_location_for_app
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module RailsOtelContext
4
- VERSION = '0.9.6'
4
+ VERSION = '0.9.8'
5
5
  end
@@ -14,6 +14,7 @@ require 'rails_otel_context/adapters'
14
14
  require 'rails_otel_context/request_context'
15
15
  require 'rails_otel_context/frame_context'
16
16
  require 'rails_otel_context/call_context_processor'
17
+ require 'rails_otel_context/body_capture'
17
18
  require 'rails_otel_context/railtie' if defined?(Rails::Railtie)
18
19
 
19
20
  module RailsOtelContext
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rails-otel-context
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.6
4
+ version: 0.9.8
5
5
  platform: ruby
6
6
  authors:
7
7
  - Last9
@@ -66,10 +66,12 @@ files:
66
66
  - lib/rails_otel_context/activerecord_context.rb
67
67
  - lib/rails_otel_context/adapters.rb
68
68
  - lib/rails_otel_context/adapters/clickhouse.rb
69
+ - lib/rails_otel_context/adapters/connection_pool.rb
69
70
  - lib/rails_otel_context/adapters/mysql2.rb
70
71
  - lib/rails_otel_context/adapters/pg.rb
71
72
  - lib/rails_otel_context/adapters/redis.rb
72
73
  - lib/rails_otel_context/adapters/trilogy.rb
74
+ - lib/rails_otel_context/body_capture.rb
73
75
  - lib/rails_otel_context/call_context_processor.rb
74
76
  - lib/rails_otel_context/configuration.rb
75
77
  - lib/rails_otel_context/frame_context.rb